diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 47719f98..c6f00ac0 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.6 + VERSION: 10.0.7 jobs: build-and-deploy: runs-on: ubuntu-latest 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 f360b76d..561ff02c 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 @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,50 @@ 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 gotcha above, nothing gets registered even though the config exists. +## 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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + 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 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` + 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 confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 e4bbdd0f..417ed0cb 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 @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -148,10 +203,30 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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 @@ -303,6 +378,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. 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 f87cc760..531bfe11 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 @@ -353,10 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case (see -`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on -`TenantsController`, not a dedicated `TenantDomainsController`) — check this deliberately rather -than assuming inference works. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` 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 d4bb537d..6bb5bd37 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -265,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style - runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy - time — that's not this skill's job; the app has exactly one data provider, chosen once, here. - Add only the one migration step matching that provider, unconditionally. Don't add the other - two providers' steps as dormant `if:`-guarded alternatives — unreachable steps (and the - `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not - documentation, and a workflow file is not the place to leave every road not taken. If this is - *replacing* an existing provider, remove that provider's migration step (and any env vars only - it needed) rather than leaving it disabled alongside the new one. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. 2. **Migration step** — add the one step below matching the chosen provider, placed after `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the 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 d7538302..994e8e3f 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith`/`Contains` 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. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-event-handler/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..f333eac3 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..34089aa4 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -154,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/Api.ApiClients.Audit/.claude/skills/nano-define-api-client/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-define-api-client/SKILL.md deleted file mode 100644 index d6eda4d7..00000000 --- a/Api.ApiClients.Audit/.claude/skills/nano-define-api-client/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: nano-define-api-client -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications — the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first — it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead — point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below — there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity — - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead — otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) — pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle — Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes — don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed — adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies — one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action — name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name — this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case — gateway -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) — so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side — per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly — defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT — don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` — the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed — the client class name and every custom method added — since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead — this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 1. **Workflow steps** — remove ` Database Migration` (this is the only migration step - present — `nano-add-data-provider` no longer adds the other two providers' steps as dormant - `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds together for that provider). 2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — - `nano-add-data-provider` no longer adds one). Only remove + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/Api.ApiClients.Audit/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.ApiClients.Audit/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md deleted file mode 100644 index 0433d19f..00000000 --- a/Api.ApiClients.Audit/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nano-scaffold-custom-endpoint -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - controller action, Request/Response DTOs, and (when the endpoint composes another Nano application) the backing Api Client method - following Nano framework and this solution's established conventions. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover — the -controller action, its `Request`/`Response` DTOs, and, if the action needs to call another Nano -application, the Api Client call that backs it. Read AGENTS.md's `### Controllers` and -`### Api Clients` sections first; this skill does not repeat those, only how to combine them for -one custom action following this solution's own established conventions (the pattern built out -across `Api.Platform`/`Api.Admin` this session: one-liner XML doc summaries, `[ProducesResponseType]` -per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). - -## Before generating anything, determine - -1. **Is this actually custom?** Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a - custom endpoint is only warranted if a single call can't compose the existing generic - `.Entity`/`.Auth`/`.Audit`/`.Identity` methods (plus `[Include]`d reads) and query criteria - can't express the filter. If the generic surface already covers this, say so and point the - user at that instead of scaffolding something redundant — don't build a custom action just - because it was asked for without checking first. -2. **Which kind of controller is this?** The shape of everything below depends on this, and it's - not always obvious from the request alone: - - **Gateway controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or - a custom client method) into one response; it has no `IRepository` of its own. - - **Internal service controller** — the action implements logic directly against this app's - own `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity - backing it at all. - If the target app/controller isn't named, or it's not clear from context which kind applies, - **ask** — don't default to one. Getting this wrong means the wrong constructor, the wrong - dependency, and a DTO shape aimed at the wrong layer. -3. **Endpoint shape.** HTTP verb, route segment, input shape (route/query/body parameters), and - response shape. Ask for whatever isn't already given — don't invent fields, routes, or status - codes that weren't asked for or that don't match an existing sibling action's pattern in the - same controller/project. -4. **Does the backing call already exist?** For a gateway controller, check whether the Api - Client(s) it needs are already injected in this controller (or injectable without issue) and - whether the specific call needed is already a generic method or an existing custom method. - - If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the - user whether to create it now** (that's `nano-define-api-client`'s job, in the *owning* - service's own project — a different application than this controller likely lives in). - Don't invoke that skill automatically, and don't scaffold this action against a method that - doesn't exist yet as if it already does. Proceed with this skill only once the user has - confirmed whether/how that gets created. If that new custom request ends up without a - response, or its response doesn't resolve (by pluralized name) to the controller the action - actually lives on — the common case for a gateway/cross-service custom endpoint, whose - response is often a bespoke DTO or which piggy-backs on a controller unrelated to the - response's own name — `nano-define-api-client` must set `this.Controller` explicitly in - that request's constructor; don't assume Nano's route-inference will find the right - controller on its own. -5. **Naming and location conventions.** Skim an existing custom action in the same controller (or - a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has - an established shape for this (see `Api.Platform`/`Api.Admin`'s `AccountsController`, - `TenantsController`, etc.), don't invent a new one. - -## Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the controller's own app project — **this is -a gateway-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even when an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -- Only include properties the endpoint actually needs — no speculative fields. -- `[Required]` on anything that must be present; match the nullable-reference style already used - by sibling `Request` classes in the same project. - -## Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the client needs — not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. - -## Controller action - -Add to an existing controller, or create a new one deriving from `BaseController` (gateway) or -the appropriate entity controller base (internal service, per AGENTS.md's Entity controller -hierarchy) if no suitable controller exists yet — follow `nano-scaffold-entity`'s controller-file -conventions for a brand new controller's shape/naming. - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Gateway: compose injected Api Client(s). - // Internal service: use IRepository/IEventing directly. - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - — match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if - it calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment, per the pattern used earlier this - session for pre-auth service calls. -- **Route constant sharing** applies only when this controller is itself the *target* of another - application's Api Client call (i.e., this is an internal service's real controller, not a - gateway) — in that case, define the route segment as a constant in `{Name}.Models/Consts/` - (this project's existing convention — see e.g. `Svc.Accounts.Models/Consts/`) and reference it - from both this `[Route(...)]` and the calling side's custom request's action attribute, per - AGENTS.md. A gateway controller with no Api Client pointed at it has no such constant to - share. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action (gateway or internal-service) needs a piece of the caller's identity - further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` - and pass it explicitly as a field on the outgoing custom request — don't rely on the target - service re-extracting the same claim from the JWT Nano forwards alongside the call. This keeps - claim-parsing in one place and means the target doesn't need a real, matching tenant behind the - forwarded token just to exercise the endpoint in `Development`. - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and — if applicable — the Api Client method it calls) and which project each lives in. -- If step 4 surfaced a missing custom Api Client method the user hasn't decided on yet, that's - the natural stopping point — don't scaffold the controller action against a call that doesn't - exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response — explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-scaffold-entity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-scaffold-entity/SKILL.md deleted file mode 100644 index dcfe201b..00000000 --- a/Api.ApiClients.Audit/.claude/skills/nano-scaffold-entity/SKILL.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -name: nano-scaffold-entity -description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). ---- - -# Nano entity scaffold - -Generates the files Nano needs for a new entity: data model and EF Core mapping always; query -criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read -`AGENTS.md` in the target repo root first if present — it documents the exact base classes and -gotchas for that specific solution; this skill assumes the general Nano.Library conventions and -defers to a project's own AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). - An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context — with no Data provider, the generated files would be dead code - with nothing to persist them. If none is registered, stop and tell the user a Data provider - needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request — need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication`. - - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 - entirely (query criteria and controllers are API-request concepts; a Console app has - nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app — otherwise just skip silently; a Console - app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never - `Controllers/`/`Criterias/`. -4. **Project layout.** Look for a `.Models` project alongside the main app project - (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists): entity model and query criteria (if - applicable) go in the `.Models` project (they're part of the API client contract other - services consume); the mapping and controller (if applicable) go in the main app project. - - **Single-project layout** (no `.Models` project): all files go in the one app project. -5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently — it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -6. **Existing conventions.** Skim one existing entity/mapping (and controller, if - applicable) triplet in the project (if any exist) for property style, nullable-reference - usage, and namespace layout, and match it. -7. **Every entity gets a generic controller — full stop, independent of whatever else exists for - it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** (see step 8 — that's a separate, - later check, not the thing that decides whether a controller gets made at all). When retrofitting - controllers onto entities that already exist: for each - entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) — every entity, not just the ones an - Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** — move on to the next entity. -8. **Only after every entity has its generic controller (step 7): does an Api Client already - define custom methods/requests targeting one of them?** Check `{TargetApp}.Models/Api/{ClientName}.cs` - and its `Api/Requests/` folder (per `nano-define-api-client`) for requests whose - `this.Controller` (explicit or inferred) points at an entity's controller. This check only adds - *extra* custom action stubs on top of the generic controller that step 7 already guarantees - exists — it never substitutes for it, and an entity with no matching Api Client requests still - gets its plain generic controller from step 7, nothing less. For each matching request found, - File 4 below scaffolds a matching controller action **stub** — signature, route, and - attributes, body `throw new NotImplementedException();` — not the real implementation. - Scaffolding the contract is this skill's job; implementing the actual business logic is not. - -## File 1 — Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's - existing convention (see step 5 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. -- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** An entity - marked `[Subscribe]` (AGENTS.md's `### Entity Events`) is a local replica kept in sync by the - built-in `EntityEventingHandler` whenever the publishing app's source entity changes — - update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP - surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its - controller (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be - exposed to callers on a subscribed entity. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 — Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout — mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly — including navigations and - collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, - convention-inferred relationship is exactly the kind of mistake that's invisible until it's a - production bug (e.g. EF silently creating a shadow FK column for a stray navigation property - with no real relationship behind it). Being explicit is what makes a mistake visible on read, - not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ - `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This - makes the mapping file scannable against the entity file side by side: a missing or - out-of-place property is immediately visible, not something that only surfaces when something - breaks at runtime. - - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` / `.HasMaxLength(n)` matching - the property's own nullability/attributes (`[MaxLength]` if present, or the property's - non-nullable reference-type status) — not just whatever EF would infer unprompted. - - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined - `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned - where the pair sits in the property order. - - **Inverse collection/reference navigation with no FK of its own** (the principal side of a - relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` - (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever - the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` - itself, that's declared once, on the dependent side. EF Core matches the two configurations by - navigation pairing and merges them as the same relationship, so writing it from both ends is - safe as long as they agree; it's what makes the relationship visible when scanning *this* - entity's mapping file alone, not just the other one. **No comment needed** — the - `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating - "FK owned/declared in the other file" for every single one of these is noise, not - information. - - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually - support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be — don't guess a - relationship that isn't in the model. If the user says to leave the property in place without - resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather - than leaving it for EF's convention to silently invent something. -- No registration step needed — Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity — check `Migrations/` for - precedent first). - -## File 3 — Query criteria (API/Web only — skip for Console) - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by — don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - — check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 — Controller (API/Web only — skip for Console) - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument — an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 5), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every - entity, with no exceptions other than `[Subscribe]`.** 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. -- No manual registration needed — Nano's MVC discovery picks up the controller - automatically from the assembly. - -### Custom action stubs (per step 8 above) - -For each matching Api Client request found: add one action to the controller, using the -request's own route constant (most real codebases define `public const string Route = "..."` -locally on the request class itself — reference it as `[Route(MyRequest.Route)]` rather than -retyping the literal, so the two sides can't drift) and the matching `[Http*]` verb attribute. -Match the doc-comment/`[ProducesResponseType]` conventions of whatever controllers already exist -in the project. The body is always a stub: - -```csharp -public virtual Task DoTheThingAsync(/* params matching the request */, CancellationToken cancellationToken = default) - // Implement. See Api.DoTheThingAsync's doc comment for the full contract. - => throw new NotImplementedException(); -``` - -- **Don't narrow the tier to dodge a collision — flag it instead.** The default tier (above) is - full CRUD regardless of what custom actions exist; if a custom request's route+verb is - literally identical to a route the generic tier also exposes (e.g. a custom - `[PostAction("create")]` alongside generic `POST .../create`), that's a genuine defect in the - Request-side contract (one of the two routes needs to change), not a reason to remove the - entity's generic capability. Add the custom action anyway, with a prominent comment naming - exactly which generic route it collides with (verb + path + which AGENTS.md table row), and - leave both in place for the user to resolve. -- **Check built-in routes too, not just the generic CRUD table** — a narrower base class can have - its own built-in action set with its own routes (e.g. `BaseEntityUserController`'s identity - actions, AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). - A custom request whose route matches one of those collides exactly the same way a generic CRUD - route would — flag it the same way. -- **If two *custom* requests collide with each other** (same controller, same route+verb): this is - a genuine defect in the Request-side contract, not something to silently rename or merge. - Scaffold both stubs anyway, with a prominent comment on each naming the other action it collides - with — flag it for the user to resolve (one of the two routes needs to change, or the two actions - need merging into one) rather than guessing. - -## After generating - -- Show the user the files generated and where they were placed (two for Console, four for - API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding - the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Audit/.claude/skills/nano-undefine-api-client/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-undefine-api-client/SKILL.md deleted file mode 100644 index c1c27336..00000000 --- a/Api.ApiClients.Audit/.claude/skills/nano-undefine-api-client/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: nano-undefine-api-client -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition — the `BaseApiClient` subclass and/or its custom request -types — from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) — point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" — confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** — either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding — this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** — if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) — leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too — otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup — - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.Audit/.github/copilot-instructions.md b/Api.ApiClients.Audit/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/Api.ApiClients.Audit/.github/copilot-instructions.md +++ b/Api.ApiClients.Audit/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-api-client.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**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 -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -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 - 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`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `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, - 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 - 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 - 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 - 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 - - 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 - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't 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 -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -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. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- 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. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 a87c0d62..7ae3707e 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 @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +193,89 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +301,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +325,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +376,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml 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 new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 ef032dc8..fecad9a6 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -88,10 +94,13 @@ 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. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,25 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +321,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +376,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +451,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +494,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-event-handler.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-logging-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-logging-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-metrics.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-metrics.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-public-exposure.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..d7f6884f 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-public-exposure.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-startup-task.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-startup-task.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-storage-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..15048379 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-storage-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -154,22 +165,33 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +295,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +323,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-define-api-client.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/Api.ApiClients.Audit/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's 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 new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-data-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..91df5c29 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-data-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-event-handler.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-health-checks.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-health-checks.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/Api.ApiClients.Audit/.github/prompts/nano-remove-identity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-remove-identity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-scaffold-custom-endpoint.prompt.md deleted file mode 100644 index c22a5b80..00000000 --- a/Api.ApiClients.Audit/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -mode: agent -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read -AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them -following this solution's own established conventions (one-liner XML doc summaries, -`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the -controller's own namespace). - -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. - -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. - ---- - -## Step 1 - Confirm a custom endpoint is actually needed - -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted -once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: - -- **Can the desired response be expressed as the target entity plus some of its navigation - properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the - needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher - to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. -- **Two real limits of that mechanism, either of which can still justify going custom even when the - shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which - tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but - never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; - a custom endpoint can. - - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation - genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size - concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped - custom endpoint instead of tagging it. -- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the - entity, responses composed from more than one unrelated entity graph, or actual business logic - beyond read/write. A representative case: an action that has to validate something (e.g. an - email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step - is expressible as a single generic `.Entity` call, and splitting them into two separate generic - calls from the caller would let the write happen without the validation ever running. This is - the right call for a custom endpoint, not a sign to keep looking for a generic-composition way - around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. -- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. -- **Is the actual need "the generic write plus an invariant that must always hold," not a new - route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect - after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a - case for **overriding the generic CRUD action** on the owning entity's own controller, not adding - a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action - instead of a new endpoint** below before scaffolding a new route for this. -- **Before designing a custom action (or a composition) around a delete or an update, check what - the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already - removes the dependent row(s) at the database level, so an explicit second delete call for that - child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable - (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL - `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse - gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic - Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child - collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade - already makes unnecessary, or assuming a collection reassignment does something it doesn't. - -If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a -custom action just because it was asked for without checking first. Note that adding a new -`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. - -## Step 2 - Public API or internal service? - -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this -wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: - -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own - `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing - it at all. Go to **Internal service path** below. - -## Step 3 - Pin down shape and conventions - -- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't - asked for or that don't match an existing sibling action's pattern in the same - controller/project. -- **Naming and location conventions.** Skim an existing custom action in the same controller (or a - sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an - established shape for this, don't invent a new one. - ---- - -## Shared DTO conventions - -Both paths below build request/response DTOs the same way - read this once, apply it wherever a -DTO comes up in either path: - -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a - request) only what the *caller* should be able to set, never fields that represent - internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even - if the controller action happens to build an entity from the request afterward. -- **Match validation attributes to what the underlying entity/write actually needs, not just - `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a - bad request is rejected by model binding before it ever reaches an Api Client call or a repository - write, instead of surfacing as a downstream 400/500. -- **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to - stitch together whatever the response needs before reaching for a custom Response DTO.** A custom - endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. -- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not - just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in - the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response - constructor/mapping actually walks and confirm every property on it is tagged before assuming - `[Include]` "already covers this." - ---- - -## Public API path - -The controller composes calls that already exist elsewhere - this path never defines a new Api -Client method of its own. - -### Does the backing call already exist? - -Check whether the Api Client(s) this action needs are already injected in this controller (or -injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. - -If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a -different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. - -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client -method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the -target's Api Client class that does nothing but forward to the generic method. This isn't limited -to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on -what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's -more than one line. A custom Api Client method should only exist when it's paired with a -controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped -or not, just hides what's actually happening for no benefit. - -**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely -to confirm the id exists before calling one is duplicated work the service already does. Only look -something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip -the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops -a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off -rather than a silent one. - -### Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the Public API's own app project - **this is a -Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -`[Required]` on anything that must be present; match the nullable-reference style already used by -sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else -belongs on this class. - -### Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. See **Shared DTO -conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. - -### Controller action - -Add to an existing Public API controller, or create a new one deriving from `BaseController` if no -suitable controller exists yet: - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Compose injected Api Client(s). - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - - match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it - calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action needs a piece of the caller's identity further downstream, **read it - here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim - from the JWT Nano forwards alongside the call. - ---- - -## Internal service path - -This action **is** a new piece of contract another application will call - scaffolding it means -scaffolding both halves together: the controller action, and the paired Api Client custom -request/method that lets other applications actually call it. - -### Does this app's own Api Client class exist yet? - -Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. - -### Shared body model - -If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** -the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind -to, not two separate DTOs kept in sync by hand: - -```csharp -public class -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a -`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't -come from the entity itself. - -### Api Client request and method - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body -model from above: - -```csharp -[PostAction(MyActionRoutes.MY_ACTION)] -public class MyActionRequest : BaseRequest -{ - [Body] - public virtual MyAction Model { get; set; } = null!; - - public MyActionRequest() - { - this.Controller = "MyEntities"; - } -} -``` - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response -genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity -controller that still returns that entity). Set `this.Controller` explicitly in the constructor -only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply. - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests -avoid drift exactly this way. - -Add the corresponding method to this app's own Api Client class: - -```csharp -public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) -{ - return this.InvokeAsync(new MyActionRequest - { - Model = model - }, cancellationToken); -} -``` - -One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no -response) or `this.InvokeAsync(request, cancellationToken)` (typed response). -Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. - -**If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for -`TResponse`, which for a collection response means `null` (not-found) is already distinguishable -from an empty collection (found, nothing to return) with no extra plumbing. Have the controller -action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. - -**The method's parameter is the shared body model itself, not its properties spread out as -separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body -model (previous section) is that it *is* the contract's shape; re-exploding it into scalar -parameters here just to reconstruct the same object one line later is pointless indirection, and -it makes the client method's signature drift from the model instead of just being it. Only -parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like -`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) -belong as their own parameter alongside the model. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded -token instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce -it, only document the expectation. - -### Controller action - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The . -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route(MyActionRoutes.MY_ACTION)] -[ProducesResponseType((int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) -{ - // Use IRepository/IEventing directly. - - return this.Ok(); -} -``` - -- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming - (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an - entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. -- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the - action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary - constructor's `repository`/`eventing` parameters are already passed to the base constructor: - referencing the same parameter again inside a method captures it a second time and is a compile - error (CS9107 - "captured into the state of the enclosing type and its value is also passed to - the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. -- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. -- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A - plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per - AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` - 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling - Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a - not-found case that specifically needs a message/code rather than a bare 404: - `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly - which generic route it collides with (verb + path + which AGENTS.md table row), and leave both - in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. - `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: - `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. -- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is - `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base - implementation, or reproduce its persistence step, then add the extra behavior) on the *same* - route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. - Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's - doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom - actions should still avoid the base routes entirely; don't reach for an override as a shortcut - to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. - -#### Overriding a generic CRUD action instead of a new endpoint - -The case above generalizes into a real alternative to scaffolding a new custom action: whenever -the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant -`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to -them. This enforces the rule as a property of the *entity's own controller*, so it holds for every -consumer, not just the one Public API that remembered to compose it. - -- **Cover every generic write variant the invariant must survive, not just the one your current - caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity - variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, - `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). - If the invariant genuinely must always hold, override all of the single-entity variants a caller - could plausibly reach; overriding only the one your current Public API calls leaves the same gap - a new custom endpoint would have needed to close anyway, just via a different route. -- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ - `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before - calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking - row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra - caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ - `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a - bespoke custom action could. If per this solution's convention a downstream service doesn't - parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a - generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform - tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a - scoped `QueryFirst`) before calling the generic write; the override and the Public API check are - complementary, not either-or. -- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row - 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 - non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of - `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another - entity's existence, check both directions before assuming only deletes need guarding. -- **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an - `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update - scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or - after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed - to. Overriding moves *where* this logic lives, not whether it's still needed. -- **Duplicate the validation across each overridden variant rather than extracting a shared private - helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block - repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. -- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the - error comes from a bespoke custom action or an overridden generic one. -- **If this action's route collides with another custom action's route** (same controller, same - route+verb): a genuine defect in the request-side contract, not something to silently rename or - merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims - from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already - supplied explicitly on the request, which is the normal case for an internal-service action's - own use of caller context. - ---- - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, - and the Api Client method) and which project each lives in. -- **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real - implementation too. -- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action - against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-scaffold-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/Api.ApiClients.Audit/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 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. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-undefine-api-client.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/Api.ApiClients.Audit/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 4255c02b..9da98eba 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -46,10 +46,12 @@ inside `{name}/`. | `{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. | | `.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/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. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -80,7 +82,7 @@ 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 `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +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, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -208,7 +210,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -369,6 +371,93 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for @@ -1467,6 +1556,92 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. @@ -1509,9 +1684,31 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1672,7 +1869,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1680,6 +1881,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1786,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -1879,7 +2100,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers @@ -3073,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler 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 f360b76d..561ff02c 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 @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,50 @@ 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 gotcha above, nothing gets registered even though the config exists. +## 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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + 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 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` + 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 confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 e4bbdd0f..417ed0cb 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 @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -148,10 +203,30 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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 @@ -303,6 +378,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. 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 f87cc760..531bfe11 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 @@ -353,10 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case (see -`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on -`TenantsController`, not a dedicated `TenantDomainsController`) — check this deliberately rather -than assuming inference works. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` 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 d4bb537d..6bb5bd37 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -265,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style - runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy - time — that's not this skill's job; the app has exactly one data provider, chosen once, here. - Add only the one migration step matching that provider, unconditionally. Don't add the other - two providers' steps as dormant `if:`-guarded alternatives — unreachable steps (and the - `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not - documentation, and a workflow file is not the place to leave every road not taken. If this is - *replacing* an existing provider, remove that provider's migration step (and any env vars only - it needed) rather than leaving it disabled alongside the new one. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. 2. **Migration step** — add the one step below matching the chosen provider, placed after `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md index d7538302..994e8e3f 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith`/`Contains` 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. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-event-handler/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..f333eac3 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..34089aa4 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -154,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/Api.ApiClients.Entity/.claude/skills/nano-define-api-client/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-define-api-client/SKILL.md deleted file mode 100644 index d6eda4d7..00000000 --- a/Api.ApiClients.Entity/.claude/skills/nano-define-api-client/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: nano-define-api-client -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications — the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first — it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead — point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below — there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity — - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead — otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) — pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle — Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes — don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed — adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies — one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action — name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name — this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case — gateway -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) — so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side — per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly — defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT — don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` — the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed — the client class name and every custom method added — since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead — this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 1. **Workflow steps** — remove ` Database Migration` (this is the only migration step - present — `nano-add-data-provider` no longer adds the other two providers' steps as dormant - `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds together for that provider). 2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — - `nano-add-data-provider` no longer adds one). Only remove + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/Api.ApiClients.Entity/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.ApiClients.Entity/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md deleted file mode 100644 index 0433d19f..00000000 --- a/Api.ApiClients.Entity/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nano-scaffold-custom-endpoint -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - controller action, Request/Response DTOs, and (when the endpoint composes another Nano application) the backing Api Client method - following Nano framework and this solution's established conventions. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover — the -controller action, its `Request`/`Response` DTOs, and, if the action needs to call another Nano -application, the Api Client call that backs it. Read AGENTS.md's `### Controllers` and -`### Api Clients` sections first; this skill does not repeat those, only how to combine them for -one custom action following this solution's own established conventions (the pattern built out -across `Api.Platform`/`Api.Admin` this session: one-liner XML doc summaries, `[ProducesResponseType]` -per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). - -## Before generating anything, determine - -1. **Is this actually custom?** Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a - custom endpoint is only warranted if a single call can't compose the existing generic - `.Entity`/`.Auth`/`.Audit`/`.Identity` methods (plus `[Include]`d reads) and query criteria - can't express the filter. If the generic surface already covers this, say so and point the - user at that instead of scaffolding something redundant — don't build a custom action just - because it was asked for without checking first. -2. **Which kind of controller is this?** The shape of everything below depends on this, and it's - not always obvious from the request alone: - - **Gateway controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or - a custom client method) into one response; it has no `IRepository` of its own. - - **Internal service controller** — the action implements logic directly against this app's - own `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity - backing it at all. - If the target app/controller isn't named, or it's not clear from context which kind applies, - **ask** — don't default to one. Getting this wrong means the wrong constructor, the wrong - dependency, and a DTO shape aimed at the wrong layer. -3. **Endpoint shape.** HTTP verb, route segment, input shape (route/query/body parameters), and - response shape. Ask for whatever isn't already given — don't invent fields, routes, or status - codes that weren't asked for or that don't match an existing sibling action's pattern in the - same controller/project. -4. **Does the backing call already exist?** For a gateway controller, check whether the Api - Client(s) it needs are already injected in this controller (or injectable without issue) and - whether the specific call needed is already a generic method or an existing custom method. - - If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the - user whether to create it now** (that's `nano-define-api-client`'s job, in the *owning* - service's own project — a different application than this controller likely lives in). - Don't invoke that skill automatically, and don't scaffold this action against a method that - doesn't exist yet as if it already does. Proceed with this skill only once the user has - confirmed whether/how that gets created. If that new custom request ends up without a - response, or its response doesn't resolve (by pluralized name) to the controller the action - actually lives on — the common case for a gateway/cross-service custom endpoint, whose - response is often a bespoke DTO or which piggy-backs on a controller unrelated to the - response's own name — `nano-define-api-client` must set `this.Controller` explicitly in - that request's constructor; don't assume Nano's route-inference will find the right - controller on its own. -5. **Naming and location conventions.** Skim an existing custom action in the same controller (or - a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has - an established shape for this (see `Api.Platform`/`Api.Admin`'s `AccountsController`, - `TenantsController`, etc.), don't invent a new one. - -## Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the controller's own app project — **this is -a gateway-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even when an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -- Only include properties the endpoint actually needs — no speculative fields. -- `[Required]` on anything that must be present; match the nullable-reference style already used - by sibling `Request` classes in the same project. - -## Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the client needs — not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. - -## Controller action - -Add to an existing controller, or create a new one deriving from `BaseController` (gateway) or -the appropriate entity controller base (internal service, per AGENTS.md's Entity controller -hierarchy) if no suitable controller exists yet — follow `nano-scaffold-entity`'s controller-file -conventions for a brand new controller's shape/naming. - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Gateway: compose injected Api Client(s). - // Internal service: use IRepository/IEventing directly. - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - — match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if - it calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment, per the pattern used earlier this - session for pre-auth service calls. -- **Route constant sharing** applies only when this controller is itself the *target* of another - application's Api Client call (i.e., this is an internal service's real controller, not a - gateway) — in that case, define the route segment as a constant in `{Name}.Models/Consts/` - (this project's existing convention — see e.g. `Svc.Accounts.Models/Consts/`) and reference it - from both this `[Route(...)]` and the calling side's custom request's action attribute, per - AGENTS.md. A gateway controller with no Api Client pointed at it has no such constant to - share. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action (gateway or internal-service) needs a piece of the caller's identity - further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` - and pass it explicitly as a field on the outgoing custom request — don't rely on the target - service re-extracting the same claim from the JWT Nano forwards alongside the call. This keeps - claim-parsing in one place and means the target doesn't need a real, matching tenant behind the - forwarded token just to exercise the endpoint in `Development`. - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and — if applicable — the Api Client method it calls) and which project each lives in. -- If step 4 surfaced a missing custom Api Client method the user hasn't decided on yet, that's - the natural stopping point — don't scaffold the controller action against a call that doesn't - exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response — explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-scaffold-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-scaffold-entity/SKILL.md deleted file mode 100644 index dcfe201b..00000000 --- a/Api.ApiClients.Entity/.claude/skills/nano-scaffold-entity/SKILL.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -name: nano-scaffold-entity -description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). ---- - -# Nano entity scaffold - -Generates the files Nano needs for a new entity: data model and EF Core mapping always; query -criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read -`AGENTS.md` in the target repo root first if present — it documents the exact base classes and -gotchas for that specific solution; this skill assumes the general Nano.Library conventions and -defers to a project's own AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). - An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context — with no Data provider, the generated files would be dead code - with nothing to persist them. If none is registered, stop and tell the user a Data provider - needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request — need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication`. - - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 - entirely (query criteria and controllers are API-request concepts; a Console app has - nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app — otherwise just skip silently; a Console - app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never - `Controllers/`/`Criterias/`. -4. **Project layout.** Look for a `.Models` project alongside the main app project - (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists): entity model and query criteria (if - applicable) go in the `.Models` project (they're part of the API client contract other - services consume); the mapping and controller (if applicable) go in the main app project. - - **Single-project layout** (no `.Models` project): all files go in the one app project. -5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently — it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -6. **Existing conventions.** Skim one existing entity/mapping (and controller, if - applicable) triplet in the project (if any exist) for property style, nullable-reference - usage, and namespace layout, and match it. -7. **Every entity gets a generic controller — full stop, independent of whatever else exists for - it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** (see step 8 — that's a separate, - later check, not the thing that decides whether a controller gets made at all). When retrofitting - controllers onto entities that already exist: for each - entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) — every entity, not just the ones an - Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** — move on to the next entity. -8. **Only after every entity has its generic controller (step 7): does an Api Client already - define custom methods/requests targeting one of them?** Check `{TargetApp}.Models/Api/{ClientName}.cs` - and its `Api/Requests/` folder (per `nano-define-api-client`) for requests whose - `this.Controller` (explicit or inferred) points at an entity's controller. This check only adds - *extra* custom action stubs on top of the generic controller that step 7 already guarantees - exists — it never substitutes for it, and an entity with no matching Api Client requests still - gets its plain generic controller from step 7, nothing less. For each matching request found, - File 4 below scaffolds a matching controller action **stub** — signature, route, and - attributes, body `throw new NotImplementedException();` — not the real implementation. - Scaffolding the contract is this skill's job; implementing the actual business logic is not. - -## File 1 — Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's - existing convention (see step 5 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. -- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** An entity - marked `[Subscribe]` (AGENTS.md's `### Entity Events`) is a local replica kept in sync by the - built-in `EntityEventingHandler` whenever the publishing app's source entity changes — - update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP - surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its - controller (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be - exposed to callers on a subscribed entity. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 — Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout — mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly — including navigations and - collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, - convention-inferred relationship is exactly the kind of mistake that's invisible until it's a - production bug (e.g. EF silently creating a shadow FK column for a stray navigation property - with no real relationship behind it). Being explicit is what makes a mistake visible on read, - not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ - `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This - makes the mapping file scannable against the entity file side by side: a missing or - out-of-place property is immediately visible, not something that only surfaces when something - breaks at runtime. - - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` / `.HasMaxLength(n)` matching - the property's own nullability/attributes (`[MaxLength]` if present, or the property's - non-nullable reference-type status) — not just whatever EF would infer unprompted. - - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined - `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned - where the pair sits in the property order. - - **Inverse collection/reference navigation with no FK of its own** (the principal side of a - relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` - (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever - the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` - itself, that's declared once, on the dependent side. EF Core matches the two configurations by - navigation pairing and merges them as the same relationship, so writing it from both ends is - safe as long as they agree; it's what makes the relationship visible when scanning *this* - entity's mapping file alone, not just the other one. **No comment needed** — the - `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating - "FK owned/declared in the other file" for every single one of these is noise, not - information. - - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually - support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be — don't guess a - relationship that isn't in the model. If the user says to leave the property in place without - resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather - than leaving it for EF's convention to silently invent something. -- No registration step needed — Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity — check `Migrations/` for - precedent first). - -## File 3 — Query criteria (API/Web only — skip for Console) - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by — don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - — check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 — Controller (API/Web only — skip for Console) - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument — an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 5), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every - entity, with no exceptions other than `[Subscribe]`.** 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. -- No manual registration needed — Nano's MVC discovery picks up the controller - automatically from the assembly. - -### Custom action stubs (per step 8 above) - -For each matching Api Client request found: add one action to the controller, using the -request's own route constant (most real codebases define `public const string Route = "..."` -locally on the request class itself — reference it as `[Route(MyRequest.Route)]` rather than -retyping the literal, so the two sides can't drift) and the matching `[Http*]` verb attribute. -Match the doc-comment/`[ProducesResponseType]` conventions of whatever controllers already exist -in the project. The body is always a stub: - -```csharp -public virtual Task DoTheThingAsync(/* params matching the request */, CancellationToken cancellationToken = default) - // Implement. See Api.DoTheThingAsync's doc comment for the full contract. - => throw new NotImplementedException(); -``` - -- **Don't narrow the tier to dodge a collision — flag it instead.** The default tier (above) is - full CRUD regardless of what custom actions exist; if a custom request's route+verb is - literally identical to a route the generic tier also exposes (e.g. a custom - `[PostAction("create")]` alongside generic `POST .../create`), that's a genuine defect in the - Request-side contract (one of the two routes needs to change), not a reason to remove the - entity's generic capability. Add the custom action anyway, with a prominent comment naming - exactly which generic route it collides with (verb + path + which AGENTS.md table row), and - leave both in place for the user to resolve. -- **Check built-in routes too, not just the generic CRUD table** — a narrower base class can have - its own built-in action set with its own routes (e.g. `BaseEntityUserController`'s identity - actions, AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). - A custom request whose route matches one of those collides exactly the same way a generic CRUD - route would — flag it the same way. -- **If two *custom* requests collide with each other** (same controller, same route+verb): this is - a genuine defect in the Request-side contract, not something to silently rename or merge. - Scaffold both stubs anyway, with a prominent comment on each naming the other action it collides - with — flag it for the user to resolve (one of the two routes needs to change, or the two actions - need merging into one) rather than guessing. - -## After generating - -- Show the user the files generated and where they were placed (two for Console, four for - API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding - the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Entity/.claude/skills/nano-undefine-api-client/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-undefine-api-client/SKILL.md deleted file mode 100644 index c1c27336..00000000 --- a/Api.ApiClients.Entity/.claude/skills/nano-undefine-api-client/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: nano-undefine-api-client -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition — the `BaseApiClient` subclass and/or its custom request -types — from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) — point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" — confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** — either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding — this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** — if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) — leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too — otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup — - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.Entity/.github/copilot-instructions.md b/Api.ApiClients.Entity/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/Api.ApiClients.Entity/.github/copilot-instructions.md +++ b/Api.ApiClients.Entity/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-api-client.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**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 -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -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 - 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`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `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, - 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 - 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 - 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 - 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 - - 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 - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't 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 -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -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. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- 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. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 a87c0d62..7ae3707e 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 @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +193,89 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +301,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +325,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +376,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml 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 new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 ef032dc8..fecad9a6 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -88,10 +94,13 @@ 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. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,25 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +321,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +376,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +451,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +494,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-event-handler.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-logging-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-logging-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-metrics.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-metrics.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-public-exposure.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..d7f6884f 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-public-exposure.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-startup-task.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-startup-task.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-storage-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..15048379 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-storage-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -154,22 +165,33 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +295,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +323,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-define-api-client.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/Api.ApiClients.Entity/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's 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 new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-data-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..91df5c29 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-data-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-event-handler.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-health-checks.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-health-checks.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/Api.ApiClients.Entity/.github/prompts/nano-remove-identity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-remove-identity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-scaffold-custom-endpoint.prompt.md deleted file mode 100644 index c22a5b80..00000000 --- a/Api.ApiClients.Entity/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -mode: agent -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read -AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them -following this solution's own established conventions (one-liner XML doc summaries, -`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the -controller's own namespace). - -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. - -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. - ---- - -## Step 1 - Confirm a custom endpoint is actually needed - -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted -once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: - -- **Can the desired response be expressed as the target entity plus some of its navigation - properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the - needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher - to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. -- **Two real limits of that mechanism, either of which can still justify going custom even when the - shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which - tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but - never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; - a custom endpoint can. - - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation - genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size - concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped - custom endpoint instead of tagging it. -- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the - entity, responses composed from more than one unrelated entity graph, or actual business logic - beyond read/write. A representative case: an action that has to validate something (e.g. an - email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step - is expressible as a single generic `.Entity` call, and splitting them into two separate generic - calls from the caller would let the write happen without the validation ever running. This is - the right call for a custom endpoint, not a sign to keep looking for a generic-composition way - around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. -- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. -- **Is the actual need "the generic write plus an invariant that must always hold," not a new - route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect - after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a - case for **overriding the generic CRUD action** on the owning entity's own controller, not adding - a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action - instead of a new endpoint** below before scaffolding a new route for this. -- **Before designing a custom action (or a composition) around a delete or an update, check what - the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already - removes the dependent row(s) at the database level, so an explicit second delete call for that - child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable - (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL - `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse - gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic - Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child - collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade - already makes unnecessary, or assuming a collection reassignment does something it doesn't. - -If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a -custom action just because it was asked for without checking first. Note that adding a new -`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. - -## Step 2 - Public API or internal service? - -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this -wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: - -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own - `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing - it at all. Go to **Internal service path** below. - -## Step 3 - Pin down shape and conventions - -- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't - asked for or that don't match an existing sibling action's pattern in the same - controller/project. -- **Naming and location conventions.** Skim an existing custom action in the same controller (or a - sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an - established shape for this, don't invent a new one. - ---- - -## Shared DTO conventions - -Both paths below build request/response DTOs the same way - read this once, apply it wherever a -DTO comes up in either path: - -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a - request) only what the *caller* should be able to set, never fields that represent - internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even - if the controller action happens to build an entity from the request afterward. -- **Match validation attributes to what the underlying entity/write actually needs, not just - `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a - bad request is rejected by model binding before it ever reaches an Api Client call or a repository - write, instead of surfacing as a downstream 400/500. -- **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to - stitch together whatever the response needs before reaching for a custom Response DTO.** A custom - endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. -- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not - just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in - the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response - constructor/mapping actually walks and confirm every property on it is tagged before assuming - `[Include]` "already covers this." - ---- - -## Public API path - -The controller composes calls that already exist elsewhere - this path never defines a new Api -Client method of its own. - -### Does the backing call already exist? - -Check whether the Api Client(s) this action needs are already injected in this controller (or -injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. - -If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a -different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. - -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client -method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the -target's Api Client class that does nothing but forward to the generic method. This isn't limited -to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on -what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's -more than one line. A custom Api Client method should only exist when it's paired with a -controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped -or not, just hides what's actually happening for no benefit. - -**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely -to confirm the id exists before calling one is duplicated work the service already does. Only look -something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip -the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops -a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off -rather than a silent one. - -### Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the Public API's own app project - **this is a -Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -`[Required]` on anything that must be present; match the nullable-reference style already used by -sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else -belongs on this class. - -### Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. See **Shared DTO -conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. - -### Controller action - -Add to an existing Public API controller, or create a new one deriving from `BaseController` if no -suitable controller exists yet: - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Compose injected Api Client(s). - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - - match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it - calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action needs a piece of the caller's identity further downstream, **read it - here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim - from the JWT Nano forwards alongside the call. - ---- - -## Internal service path - -This action **is** a new piece of contract another application will call - scaffolding it means -scaffolding both halves together: the controller action, and the paired Api Client custom -request/method that lets other applications actually call it. - -### Does this app's own Api Client class exist yet? - -Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. - -### Shared body model - -If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** -the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind -to, not two separate DTOs kept in sync by hand: - -```csharp -public class -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a -`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't -come from the entity itself. - -### Api Client request and method - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body -model from above: - -```csharp -[PostAction(MyActionRoutes.MY_ACTION)] -public class MyActionRequest : BaseRequest -{ - [Body] - public virtual MyAction Model { get; set; } = null!; - - public MyActionRequest() - { - this.Controller = "MyEntities"; - } -} -``` - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response -genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity -controller that still returns that entity). Set `this.Controller` explicitly in the constructor -only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply. - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests -avoid drift exactly this way. - -Add the corresponding method to this app's own Api Client class: - -```csharp -public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) -{ - return this.InvokeAsync(new MyActionRequest - { - Model = model - }, cancellationToken); -} -``` - -One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no -response) or `this.InvokeAsync(request, cancellationToken)` (typed response). -Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. - -**If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for -`TResponse`, which for a collection response means `null` (not-found) is already distinguishable -from an empty collection (found, nothing to return) with no extra plumbing. Have the controller -action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. - -**The method's parameter is the shared body model itself, not its properties spread out as -separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body -model (previous section) is that it *is* the contract's shape; re-exploding it into scalar -parameters here just to reconstruct the same object one line later is pointless indirection, and -it makes the client method's signature drift from the model instead of just being it. Only -parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like -`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) -belong as their own parameter alongside the model. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded -token instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce -it, only document the expectation. - -### Controller action - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The . -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route(MyActionRoutes.MY_ACTION)] -[ProducesResponseType((int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) -{ - // Use IRepository/IEventing directly. - - return this.Ok(); -} -``` - -- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming - (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an - entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. -- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the - action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary - constructor's `repository`/`eventing` parameters are already passed to the base constructor: - referencing the same parameter again inside a method captures it a second time and is a compile - error (CS9107 - "captured into the state of the enclosing type and its value is also passed to - the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. -- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. -- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A - plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per - AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` - 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling - Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a - not-found case that specifically needs a message/code rather than a bare 404: - `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly - which generic route it collides with (verb + path + which AGENTS.md table row), and leave both - in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. - `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: - `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. -- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is - `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base - implementation, or reproduce its persistence step, then add the extra behavior) on the *same* - route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. - Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's - doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom - actions should still avoid the base routes entirely; don't reach for an override as a shortcut - to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. - -#### Overriding a generic CRUD action instead of a new endpoint - -The case above generalizes into a real alternative to scaffolding a new custom action: whenever -the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant -`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to -them. This enforces the rule as a property of the *entity's own controller*, so it holds for every -consumer, not just the one Public API that remembered to compose it. - -- **Cover every generic write variant the invariant must survive, not just the one your current - caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity - variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, - `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). - If the invariant genuinely must always hold, override all of the single-entity variants a caller - could plausibly reach; overriding only the one your current Public API calls leaves the same gap - a new custom endpoint would have needed to close anyway, just via a different route. -- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ - `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before - calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking - row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra - caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ - `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a - bespoke custom action could. If per this solution's convention a downstream service doesn't - parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a - generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform - tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a - scoped `QueryFirst`) before calling the generic write; the override and the Public API check are - complementary, not either-or. -- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row - 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 - non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of - `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another - entity's existence, check both directions before assuming only deletes need guarding. -- **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an - `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update - scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or - after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed - to. Overriding moves *where* this logic lives, not whether it's still needed. -- **Duplicate the validation across each overridden variant rather than extracting a shared private - helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block - repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. -- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the - error comes from a bespoke custom action or an overridden generic one. -- **If this action's route collides with another custom action's route** (same controller, same - route+verb): a genuine defect in the request-side contract, not something to silently rename or - merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims - from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already - supplied explicitly on the request, which is the normal case for an internal-service action's - own use of caller context. - ---- - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, - and the Api Client method) and which project each lives in. -- **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real - implementation too. -- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action - against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-scaffold-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/Api.ApiClients.Entity/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 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. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-undefine-api-client.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/Api.ApiClients.Entity/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 4255c02b..9da98eba 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -46,10 +46,12 @@ inside `{name}/`. | `{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. | | `.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/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. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -80,7 +82,7 @@ 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 `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +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, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -208,7 +210,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -369,6 +371,93 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for @@ -1467,6 +1556,92 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. @@ -1509,9 +1684,31 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1672,7 +1869,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1680,6 +1881,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1786,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -1879,7 +2100,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers @@ -3073,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler 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 f360b76d..561ff02c 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 @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,50 @@ 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 gotcha above, nothing gets registered even though the config exists. +## 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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + 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 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` + 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 confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 e4bbdd0f..417ed0cb 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 @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -148,10 +203,30 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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 @@ -303,6 +378,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. 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 f87cc760..531bfe11 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 @@ -353,10 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case (see -`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on -`TenantsController`, not a dedicated `TenantDomainsController`) — check this deliberately rather -than assuming inference works. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` 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 d4bb537d..6bb5bd37 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -265,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style - runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy - time — that's not this skill's job; the app has exactly one data provider, chosen once, here. - Add only the one migration step matching that provider, unconditionally. Don't add the other - two providers' steps as dormant `if:`-guarded alternatives — unreachable steps (and the - `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not - documentation, and a workflow file is not the place to leave every road not taken. If this is - *replacing* an existing provider, remove that provider's migration step (and any env vars only - it needed) rather than leaving it disabled alongside the new one. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. 2. **Migration step** — add the one step below matching the chosen provider, placed after `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md index d7538302..994e8e3f 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith`/`Contains` 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. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-event-handler/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..f333eac3 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..34089aa4 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -154,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-define-api-client/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-define-api-client/SKILL.md deleted file mode 100644 index d6eda4d7..00000000 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-define-api-client/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: nano-define-api-client -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications — the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first — it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead — point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below — there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity — - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead — otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) — pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle — Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes — don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed — adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies — one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action — name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name — this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case — gateway -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) — so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side — per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly — defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT — don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` — the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed — the client class name and every custom method added — since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead — this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 1. **Workflow steps** — remove ` Database Migration` (this is the only migration step - present — `nano-add-data-provider` no longer adds the other two providers' steps as dormant - `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds together for that provider). 2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — - `nano-add-data-provider` no longer adds one). Only remove + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md deleted file mode 100644 index 0433d19f..00000000 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nano-scaffold-custom-endpoint -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - controller action, Request/Response DTOs, and (when the endpoint composes another Nano application) the backing Api Client method - following Nano framework and this solution's established conventions. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover — the -controller action, its `Request`/`Response` DTOs, and, if the action needs to call another Nano -application, the Api Client call that backs it. Read AGENTS.md's `### Controllers` and -`### Api Clients` sections first; this skill does not repeat those, only how to combine them for -one custom action following this solution's own established conventions (the pattern built out -across `Api.Platform`/`Api.Admin` this session: one-liner XML doc summaries, `[ProducesResponseType]` -per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). - -## Before generating anything, determine - -1. **Is this actually custom?** Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a - custom endpoint is only warranted if a single call can't compose the existing generic - `.Entity`/`.Auth`/`.Audit`/`.Identity` methods (plus `[Include]`d reads) and query criteria - can't express the filter. If the generic surface already covers this, say so and point the - user at that instead of scaffolding something redundant — don't build a custom action just - because it was asked for without checking first. -2. **Which kind of controller is this?** The shape of everything below depends on this, and it's - not always obvious from the request alone: - - **Gateway controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or - a custom client method) into one response; it has no `IRepository` of its own. - - **Internal service controller** — the action implements logic directly against this app's - own `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity - backing it at all. - If the target app/controller isn't named, or it's not clear from context which kind applies, - **ask** — don't default to one. Getting this wrong means the wrong constructor, the wrong - dependency, and a DTO shape aimed at the wrong layer. -3. **Endpoint shape.** HTTP verb, route segment, input shape (route/query/body parameters), and - response shape. Ask for whatever isn't already given — don't invent fields, routes, or status - codes that weren't asked for or that don't match an existing sibling action's pattern in the - same controller/project. -4. **Does the backing call already exist?** For a gateway controller, check whether the Api - Client(s) it needs are already injected in this controller (or injectable without issue) and - whether the specific call needed is already a generic method or an existing custom method. - - If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the - user whether to create it now** (that's `nano-define-api-client`'s job, in the *owning* - service's own project — a different application than this controller likely lives in). - Don't invoke that skill automatically, and don't scaffold this action against a method that - doesn't exist yet as if it already does. Proceed with this skill only once the user has - confirmed whether/how that gets created. If that new custom request ends up without a - response, or its response doesn't resolve (by pluralized name) to the controller the action - actually lives on — the common case for a gateway/cross-service custom endpoint, whose - response is often a bespoke DTO or which piggy-backs on a controller unrelated to the - response's own name — `nano-define-api-client` must set `this.Controller` explicitly in - that request's constructor; don't assume Nano's route-inference will find the right - controller on its own. -5. **Naming and location conventions.** Skim an existing custom action in the same controller (or - a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has - an established shape for this (see `Api.Platform`/`Api.Admin`'s `AccountsController`, - `TenantsController`, etc.), don't invent a new one. - -## Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the controller's own app project — **this is -a gateway-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even when an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -- Only include properties the endpoint actually needs — no speculative fields. -- `[Required]` on anything that must be present; match the nullable-reference style already used - by sibling `Request` classes in the same project. - -## Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the client needs — not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. - -## Controller action - -Add to an existing controller, or create a new one deriving from `BaseController` (gateway) or -the appropriate entity controller base (internal service, per AGENTS.md's Entity controller -hierarchy) if no suitable controller exists yet — follow `nano-scaffold-entity`'s controller-file -conventions for a brand new controller's shape/naming. - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Gateway: compose injected Api Client(s). - // Internal service: use IRepository/IEventing directly. - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - — match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if - it calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment, per the pattern used earlier this - session for pre-auth service calls. -- **Route constant sharing** applies only when this controller is itself the *target* of another - application's Api Client call (i.e., this is an internal service's real controller, not a - gateway) — in that case, define the route segment as a constant in `{Name}.Models/Consts/` - (this project's existing convention — see e.g. `Svc.Accounts.Models/Consts/`) and reference it - from both this `[Route(...)]` and the calling side's custom request's action attribute, per - AGENTS.md. A gateway controller with no Api Client pointed at it has no such constant to - share. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action (gateway or internal-service) needs a piece of the caller's identity - further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` - and pass it explicitly as a field on the outgoing custom request — don't rely on the target - service re-extracting the same claim from the JWT Nano forwards alongside the call. This keeps - claim-parsing in one place and means the target doesn't need a real, matching tenant behind the - forwarded token just to exercise the endpoint in `Development`. - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and — if applicable — the Api Client method it calls) and which project each lives in. -- If step 4 surfaced a missing custom Api Client method the user hasn't decided on yet, that's - the natural stopping point — don't scaffold the controller action against a call that doesn't - exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response — explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-entity/SKILL.md deleted file mode 100644 index dcfe201b..00000000 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-scaffold-entity/SKILL.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -name: nano-scaffold-entity -description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). ---- - -# Nano entity scaffold - -Generates the files Nano needs for a new entity: data model and EF Core mapping always; query -criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read -`AGENTS.md` in the target repo root first if present — it documents the exact base classes and -gotchas for that specific solution; this skill assumes the general Nano.Library conventions and -defers to a project's own AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). - An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context — with no Data provider, the generated files would be dead code - with nothing to persist them. If none is registered, stop and tell the user a Data provider - needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request — need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication`. - - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 - entirely (query criteria and controllers are API-request concepts; a Console app has - nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app — otherwise just skip silently; a Console - app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never - `Controllers/`/`Criterias/`. -4. **Project layout.** Look for a `.Models` project alongside the main app project - (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists): entity model and query criteria (if - applicable) go in the `.Models` project (they're part of the API client contract other - services consume); the mapping and controller (if applicable) go in the main app project. - - **Single-project layout** (no `.Models` project): all files go in the one app project. -5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently — it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -6. **Existing conventions.** Skim one existing entity/mapping (and controller, if - applicable) triplet in the project (if any exist) for property style, nullable-reference - usage, and namespace layout, and match it. -7. **Every entity gets a generic controller — full stop, independent of whatever else exists for - it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** (see step 8 — that's a separate, - later check, not the thing that decides whether a controller gets made at all). When retrofitting - controllers onto entities that already exist: for each - entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) — every entity, not just the ones an - Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** — move on to the next entity. -8. **Only after every entity has its generic controller (step 7): does an Api Client already - define custom methods/requests targeting one of them?** Check `{TargetApp}.Models/Api/{ClientName}.cs` - and its `Api/Requests/` folder (per `nano-define-api-client`) for requests whose - `this.Controller` (explicit or inferred) points at an entity's controller. This check only adds - *extra* custom action stubs on top of the generic controller that step 7 already guarantees - exists — it never substitutes for it, and an entity with no matching Api Client requests still - gets its plain generic controller from step 7, nothing less. For each matching request found, - File 4 below scaffolds a matching controller action **stub** — signature, route, and - attributes, body `throw new NotImplementedException();` — not the real implementation. - Scaffolding the contract is this skill's job; implementing the actual business logic is not. - -## File 1 — Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's - existing convention (see step 5 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. -- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** An entity - marked `[Subscribe]` (AGENTS.md's `### Entity Events`) is a local replica kept in sync by the - built-in `EntityEventingHandler` whenever the publishing app's source entity changes — - update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP - surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its - controller (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be - exposed to callers on a subscribed entity. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 — Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout — mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly — including navigations and - collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, - convention-inferred relationship is exactly the kind of mistake that's invisible until it's a - production bug (e.g. EF silently creating a shadow FK column for a stray navigation property - with no real relationship behind it). Being explicit is what makes a mistake visible on read, - not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ - `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This - makes the mapping file scannable against the entity file side by side: a missing or - out-of-place property is immediately visible, not something that only surfaces when something - breaks at runtime. - - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` / `.HasMaxLength(n)` matching - the property's own nullability/attributes (`[MaxLength]` if present, or the property's - non-nullable reference-type status) — not just whatever EF would infer unprompted. - - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined - `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned - where the pair sits in the property order. - - **Inverse collection/reference navigation with no FK of its own** (the principal side of a - relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` - (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever - the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` - itself, that's declared once, on the dependent side. EF Core matches the two configurations by - navigation pairing and merges them as the same relationship, so writing it from both ends is - safe as long as they agree; it's what makes the relationship visible when scanning *this* - entity's mapping file alone, not just the other one. **No comment needed** — the - `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating - "FK owned/declared in the other file" for every single one of these is noise, not - information. - - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually - support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be — don't guess a - relationship that isn't in the model. If the user says to leave the property in place without - resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather - than leaving it for EF's convention to silently invent something. -- No registration step needed — Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity — check `Migrations/` for - precedent first). - -## File 3 — Query criteria (API/Web only — skip for Console) - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by — don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - — check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 — Controller (API/Web only — skip for Console) - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument — an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 5), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every - entity, with no exceptions other than `[Subscribe]`.** 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. -- No manual registration needed — Nano's MVC discovery picks up the controller - automatically from the assembly. - -### Custom action stubs (per step 8 above) - -For each matching Api Client request found: add one action to the controller, using the -request's own route constant (most real codebases define `public const string Route = "..."` -locally on the request class itself — reference it as `[Route(MyRequest.Route)]` rather than -retyping the literal, so the two sides can't drift) and the matching `[Http*]` verb attribute. -Match the doc-comment/`[ProducesResponseType]` conventions of whatever controllers already exist -in the project. The body is always a stub: - -```csharp -public virtual Task DoTheThingAsync(/* params matching the request */, CancellationToken cancellationToken = default) - // Implement. See Api.DoTheThingAsync's doc comment for the full contract. - => throw new NotImplementedException(); -``` - -- **Don't narrow the tier to dodge a collision — flag it instead.** The default tier (above) is - full CRUD regardless of what custom actions exist; if a custom request's route+verb is - literally identical to a route the generic tier also exposes (e.g. a custom - `[PostAction("create")]` alongside generic `POST .../create`), that's a genuine defect in the - Request-side contract (one of the two routes needs to change), not a reason to remove the - entity's generic capability. Add the custom action anyway, with a prominent comment naming - exactly which generic route it collides with (verb + path + which AGENTS.md table row), and - leave both in place for the user to resolve. -- **Check built-in routes too, not just the generic CRUD table** — a narrower base class can have - its own built-in action set with its own routes (e.g. `BaseEntityUserController`'s identity - actions, AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). - A custom request whose route matches one of those collides exactly the same way a generic CRUD - route would — flag it the same way. -- **If two *custom* requests collide with each other** (same controller, same route+verb): this is - a genuine defect in the Request-side contract, not something to silently rename or merge. - Scaffold both stubs anyway, with a prominent comment on each naming the other action it collides - with — flag it for the user to resolve (one of the two routes needs to change, or the two actions - need merging into one) rather than guessing. - -## After generating - -- Show the user the files generated and where they were placed (two for Console, four for - API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding - the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-undefine-api-client/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-undefine-api-client/SKILL.md deleted file mode 100644 index c1c27336..00000000 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-undefine-api-client/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: nano-undefine-api-client -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition — the `BaseApiClient` subclass and/or its custom request -types — from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) — point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" — confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** — either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding — this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** — if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) — leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too — otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup — - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.RootLogIn/.github/copilot-instructions.md b/Api.ApiClients.RootLogIn/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/Api.ApiClients.RootLogIn/.github/copilot-instructions.md +++ b/Api.ApiClients.RootLogIn/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**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 -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -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 - 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`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `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, - 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 - 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 - 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 - 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 - - 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 - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't 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 -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -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. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- 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. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 a87c0d62..7ae3707e 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 @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +193,89 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +301,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +325,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +376,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml 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 new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 ef032dc8..fecad9a6 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -88,10 +94,13 @@ 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. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,25 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +321,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +376,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +451,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +494,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-event-handler.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-logging-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-logging-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-metrics.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-metrics.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-public-exposure.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..d7f6884f 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-public-exposure.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-startup-task.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-startup-task.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-storage-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..15048379 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-storage-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -154,22 +165,33 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +295,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +323,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-define-api-client.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's 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 new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-data-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..91df5c29 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-data-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-event-handler.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-health-checks.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-health-checks.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-identity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-identity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-custom-endpoint.prompt.md deleted file mode 100644 index c22a5b80..00000000 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -mode: agent -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read -AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them -following this solution's own established conventions (one-liner XML doc summaries, -`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the -controller's own namespace). - -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. - -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. - ---- - -## Step 1 - Confirm a custom endpoint is actually needed - -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted -once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: - -- **Can the desired response be expressed as the target entity plus some of its navigation - properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the - needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher - to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. -- **Two real limits of that mechanism, either of which can still justify going custom even when the - shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which - tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but - never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; - a custom endpoint can. - - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation - genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size - concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped - custom endpoint instead of tagging it. -- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the - entity, responses composed from more than one unrelated entity graph, or actual business logic - beyond read/write. A representative case: an action that has to validate something (e.g. an - email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step - is expressible as a single generic `.Entity` call, and splitting them into two separate generic - calls from the caller would let the write happen without the validation ever running. This is - the right call for a custom endpoint, not a sign to keep looking for a generic-composition way - around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. -- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. -- **Is the actual need "the generic write plus an invariant that must always hold," not a new - route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect - after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a - case for **overriding the generic CRUD action** on the owning entity's own controller, not adding - a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action - instead of a new endpoint** below before scaffolding a new route for this. -- **Before designing a custom action (or a composition) around a delete or an update, check what - the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already - removes the dependent row(s) at the database level, so an explicit second delete call for that - child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable - (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL - `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse - gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic - Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child - collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade - already makes unnecessary, or assuming a collection reassignment does something it doesn't. - -If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a -custom action just because it was asked for without checking first. Note that adding a new -`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. - -## Step 2 - Public API or internal service? - -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this -wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: - -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own - `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing - it at all. Go to **Internal service path** below. - -## Step 3 - Pin down shape and conventions - -- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't - asked for or that don't match an existing sibling action's pattern in the same - controller/project. -- **Naming and location conventions.** Skim an existing custom action in the same controller (or a - sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an - established shape for this, don't invent a new one. - ---- - -## Shared DTO conventions - -Both paths below build request/response DTOs the same way - read this once, apply it wherever a -DTO comes up in either path: - -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a - request) only what the *caller* should be able to set, never fields that represent - internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even - if the controller action happens to build an entity from the request afterward. -- **Match validation attributes to what the underlying entity/write actually needs, not just - `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a - bad request is rejected by model binding before it ever reaches an Api Client call or a repository - write, instead of surfacing as a downstream 400/500. -- **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to - stitch together whatever the response needs before reaching for a custom Response DTO.** A custom - endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. -- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not - just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in - the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response - constructor/mapping actually walks and confirm every property on it is tagged before assuming - `[Include]` "already covers this." - ---- - -## Public API path - -The controller composes calls that already exist elsewhere - this path never defines a new Api -Client method of its own. - -### Does the backing call already exist? - -Check whether the Api Client(s) this action needs are already injected in this controller (or -injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. - -If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a -different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. - -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client -method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the -target's Api Client class that does nothing but forward to the generic method. This isn't limited -to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on -what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's -more than one line. A custom Api Client method should only exist when it's paired with a -controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped -or not, just hides what's actually happening for no benefit. - -**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely -to confirm the id exists before calling one is duplicated work the service already does. Only look -something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip -the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops -a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off -rather than a silent one. - -### Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the Public API's own app project - **this is a -Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -`[Required]` on anything that must be present; match the nullable-reference style already used by -sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else -belongs on this class. - -### Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. See **Shared DTO -conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. - -### Controller action - -Add to an existing Public API controller, or create a new one deriving from `BaseController` if no -suitable controller exists yet: - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Compose injected Api Client(s). - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - - match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it - calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action needs a piece of the caller's identity further downstream, **read it - here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim - from the JWT Nano forwards alongside the call. - ---- - -## Internal service path - -This action **is** a new piece of contract another application will call - scaffolding it means -scaffolding both halves together: the controller action, and the paired Api Client custom -request/method that lets other applications actually call it. - -### Does this app's own Api Client class exist yet? - -Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. - -### Shared body model - -If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** -the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind -to, not two separate DTOs kept in sync by hand: - -```csharp -public class -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a -`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't -come from the entity itself. - -### Api Client request and method - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body -model from above: - -```csharp -[PostAction(MyActionRoutes.MY_ACTION)] -public class MyActionRequest : BaseRequest -{ - [Body] - public virtual MyAction Model { get; set; } = null!; - - public MyActionRequest() - { - this.Controller = "MyEntities"; - } -} -``` - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response -genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity -controller that still returns that entity). Set `this.Controller` explicitly in the constructor -only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply. - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests -avoid drift exactly this way. - -Add the corresponding method to this app's own Api Client class: - -```csharp -public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) -{ - return this.InvokeAsync(new MyActionRequest - { - Model = model - }, cancellationToken); -} -``` - -One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no -response) or `this.InvokeAsync(request, cancellationToken)` (typed response). -Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. - -**If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for -`TResponse`, which for a collection response means `null` (not-found) is already distinguishable -from an empty collection (found, nothing to return) with no extra plumbing. Have the controller -action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. - -**The method's parameter is the shared body model itself, not its properties spread out as -separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body -model (previous section) is that it *is* the contract's shape; re-exploding it into scalar -parameters here just to reconstruct the same object one line later is pointless indirection, and -it makes the client method's signature drift from the model instead of just being it. Only -parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like -`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) -belong as their own parameter alongside the model. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded -token instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce -it, only document the expectation. - -### Controller action - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The . -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route(MyActionRoutes.MY_ACTION)] -[ProducesResponseType((int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) -{ - // Use IRepository/IEventing directly. - - return this.Ok(); -} -``` - -- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming - (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an - entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. -- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the - action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary - constructor's `repository`/`eventing` parameters are already passed to the base constructor: - referencing the same parameter again inside a method captures it a second time and is a compile - error (CS9107 - "captured into the state of the enclosing type and its value is also passed to - the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. -- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. -- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A - plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per - AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` - 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling - Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a - not-found case that specifically needs a message/code rather than a bare 404: - `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly - which generic route it collides with (verb + path + which AGENTS.md table row), and leave both - in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. - `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: - `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. -- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is - `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base - implementation, or reproduce its persistence step, then add the extra behavior) on the *same* - route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. - Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's - doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom - actions should still avoid the base routes entirely; don't reach for an override as a shortcut - to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. - -#### Overriding a generic CRUD action instead of a new endpoint - -The case above generalizes into a real alternative to scaffolding a new custom action: whenever -the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant -`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to -them. This enforces the rule as a property of the *entity's own controller*, so it holds for every -consumer, not just the one Public API that remembered to compose it. - -- **Cover every generic write variant the invariant must survive, not just the one your current - caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity - variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, - `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). - If the invariant genuinely must always hold, override all of the single-entity variants a caller - could plausibly reach; overriding only the one your current Public API calls leaves the same gap - a new custom endpoint would have needed to close anyway, just via a different route. -- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ - `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before - calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking - row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra - caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ - `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a - bespoke custom action could. If per this solution's convention a downstream service doesn't - parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a - generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform - tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a - scoped `QueryFirst`) before calling the generic write; the override and the Public API check are - complementary, not either-or. -- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row - 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 - non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of - `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another - entity's existence, check both directions before assuming only deletes need guarding. -- **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an - `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update - scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or - after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed - to. Overriding moves *where* this logic lives, not whether it's still needed. -- **Duplicate the validation across each overridden variant rather than extracting a shared private - helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block - repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. -- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the - error comes from a bespoke custom action or an overridden generic one. -- **If this action's route collides with another custom action's route** (same controller, same - route+verb): a genuine defect in the request-side contract, not something to silently rename or - merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims - from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already - supplied explicitly on the request, which is the normal case for an internal-service action's - own use of caller context. - ---- - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, - and the Api Client method) and which project each lives in. -- **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real - implementation too. -- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action - against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 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. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-undefine-api-client.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 4255c02b..9da98eba 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -46,10 +46,12 @@ inside `{name}/`. | `{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. | | `.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/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. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -80,7 +82,7 @@ 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 `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +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, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -208,7 +210,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -369,6 +371,93 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for @@ -1467,6 +1556,92 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. @@ -1509,9 +1684,31 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1672,7 +1869,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1680,6 +1881,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1786,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -1879,7 +2100,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers @@ -3073,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler 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 f360b76d..561ff02c 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 @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,50 @@ 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 gotcha above, nothing gets registered even though the config exists. +## 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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + 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 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` + 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 confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 e4bbdd0f..417ed0cb 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -148,10 +203,30 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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 @@ -303,6 +378,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. 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 f87cc760..531bfe11 100644 --- a/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -353,10 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case (see -`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on -`TenantsController`, not a dedicated `TenantDomainsController`) — check this deliberately rather -than assuming inference works. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` 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 d4bb537d..6bb5bd37 100644 --- a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -265,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style - runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy - time — that's not this skill's job; the app has exactly one data provider, chosen once, here. - Add only the one migration step matching that provider, unconditionally. Don't add the other - two providers' steps as dormant `if:`-guarded alternatives — unreachable steps (and the - `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not - documentation, and a workflow file is not the place to leave every road not taken. If this is - *replacing* an existing provider, remove that provider's migration step (and any env vars only - it needed) rather than leaving it disabled alongside the new one. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. 2. **Migration step** — add the one step below matching the chosen provider, placed after `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index d7538302..994e8e3f 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith`/`Contains` 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. diff --git a/Api.ApiClients/.claude/skills/nano-add-event-handler/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/Api.ApiClients/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..f333eac3 100644 --- a/Api.ApiClients/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..34089aa4 100644 --- a/Api.ApiClients/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -154,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/Api.ApiClients/.claude/skills/nano-define-api-client/SKILL.md b/Api.ApiClients/.claude/skills/nano-define-api-client/SKILL.md deleted file mode 100644 index d6eda4d7..00000000 --- a/Api.ApiClients/.claude/skills/nano-define-api-client/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: nano-define-api-client -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications — the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first — it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead — point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below — there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity — - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead — otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) — pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle — Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes — don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed — adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies — one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action — name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name — this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case — gateway -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) — so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side — per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly — defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT — don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` — the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed — the client class name and every custom method added — since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead — this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/Api.ApiClients/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/Api.ApiClients/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/Api.ApiClients/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 1. **Workflow steps** — remove ` Database Migration` (this is the only migration step - present — `nano-add-data-provider` no longer adds the other two providers' steps as dormant - `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds together for that provider). 2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — - `nano-add-data-provider` no longer adds one). Only remove + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/Api.ApiClients/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.ApiClients/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.ApiClients/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md b/Api.ApiClients/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md deleted file mode 100644 index 0433d19f..00000000 --- a/Api.ApiClients/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nano-scaffold-custom-endpoint -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - controller action, Request/Response DTOs, and (when the endpoint composes another Nano application) the backing Api Client method - following Nano framework and this solution's established conventions. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover — the -controller action, its `Request`/`Response` DTOs, and, if the action needs to call another Nano -application, the Api Client call that backs it. Read AGENTS.md's `### Controllers` and -`### Api Clients` sections first; this skill does not repeat those, only how to combine them for -one custom action following this solution's own established conventions (the pattern built out -across `Api.Platform`/`Api.Admin` this session: one-liner XML doc summaries, `[ProducesResponseType]` -per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). - -## Before generating anything, determine - -1. **Is this actually custom?** Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a - custom endpoint is only warranted if a single call can't compose the existing generic - `.Entity`/`.Auth`/`.Audit`/`.Identity` methods (plus `[Include]`d reads) and query criteria - can't express the filter. If the generic surface already covers this, say so and point the - user at that instead of scaffolding something redundant — don't build a custom action just - because it was asked for without checking first. -2. **Which kind of controller is this?** The shape of everything below depends on this, and it's - not always obvious from the request alone: - - **Gateway controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or - a custom client method) into one response; it has no `IRepository` of its own. - - **Internal service controller** — the action implements logic directly against this app's - own `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity - backing it at all. - If the target app/controller isn't named, or it's not clear from context which kind applies, - **ask** — don't default to one. Getting this wrong means the wrong constructor, the wrong - dependency, and a DTO shape aimed at the wrong layer. -3. **Endpoint shape.** HTTP verb, route segment, input shape (route/query/body parameters), and - response shape. Ask for whatever isn't already given — don't invent fields, routes, or status - codes that weren't asked for or that don't match an existing sibling action's pattern in the - same controller/project. -4. **Does the backing call already exist?** For a gateway controller, check whether the Api - Client(s) it needs are already injected in this controller (or injectable without issue) and - whether the specific call needed is already a generic method or an existing custom method. - - If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the - user whether to create it now** (that's `nano-define-api-client`'s job, in the *owning* - service's own project — a different application than this controller likely lives in). - Don't invoke that skill automatically, and don't scaffold this action against a method that - doesn't exist yet as if it already does. Proceed with this skill only once the user has - confirmed whether/how that gets created. If that new custom request ends up without a - response, or its response doesn't resolve (by pluralized name) to the controller the action - actually lives on — the common case for a gateway/cross-service custom endpoint, whose - response is often a bespoke DTO or which piggy-backs on a controller unrelated to the - response's own name — `nano-define-api-client` must set `this.Controller` explicitly in - that request's constructor; don't assume Nano's route-inference will find the right - controller on its own. -5. **Naming and location conventions.** Skim an existing custom action in the same controller (or - a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has - an established shape for this (see `Api.Platform`/`Api.Admin`'s `AccountsController`, - `TenantsController`, etc.), don't invent a new one. - -## Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the controller's own app project — **this is -a gateway-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even when an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -- Only include properties the endpoint actually needs — no speculative fields. -- `[Required]` on anything that must be present; match the nullable-reference style already used - by sibling `Request` classes in the same project. - -## Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the client needs — not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. - -## Controller action - -Add to an existing controller, or create a new one deriving from `BaseController` (gateway) or -the appropriate entity controller base (internal service, per AGENTS.md's Entity controller -hierarchy) if no suitable controller exists yet — follow `nano-scaffold-entity`'s controller-file -conventions for a brand new controller's shape/naming. - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Gateway: compose injected Api Client(s). - // Internal service: use IRepository/IEventing directly. - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - — match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if - it calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment, per the pattern used earlier this - session for pre-auth service calls. -- **Route constant sharing** applies only when this controller is itself the *target* of another - application's Api Client call (i.e., this is an internal service's real controller, not a - gateway) — in that case, define the route segment as a constant in `{Name}.Models/Consts/` - (this project's existing convention — see e.g. `Svc.Accounts.Models/Consts/`) and reference it - from both this `[Route(...)]` and the calling side's custom request's action attribute, per - AGENTS.md. A gateway controller with no Api Client pointed at it has no such constant to - share. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action (gateway or internal-service) needs a piece of the caller's identity - further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` - and pass it explicitly as a field on the outgoing custom request — don't rely on the target - service re-extracting the same claim from the JWT Nano forwards alongside the call. This keeps - claim-parsing in one place and means the target doesn't need a real, matching tenant behind the - forwarded token just to exercise the endpoint in `Development`. - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and — if applicable — the Api Client method it calls) and which project each lives in. -- If step 4 surfaced a missing custom Api Client method the user hasn't decided on yet, that's - the natural stopping point — don't scaffold the controller action against a call that doesn't - exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response — explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients/.claude/skills/nano-scaffold-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-scaffold-entity/SKILL.md deleted file mode 100644 index dcfe201b..00000000 --- a/Api.ApiClients/.claude/skills/nano-scaffold-entity/SKILL.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -name: nano-scaffold-entity -description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). ---- - -# Nano entity scaffold - -Generates the files Nano needs for a new entity: data model and EF Core mapping always; query -criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read -`AGENTS.md` in the target repo root first if present — it documents the exact base classes and -gotchas for that specific solution; this skill assumes the general Nano.Library conventions and -defers to a project's own AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). - An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context — with no Data provider, the generated files would be dead code - with nothing to persist them. If none is registered, stop and tell the user a Data provider - needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request — need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication`. - - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 - entirely (query criteria and controllers are API-request concepts; a Console app has - nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app — otherwise just skip silently; a Console - app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never - `Controllers/`/`Criterias/`. -4. **Project layout.** Look for a `.Models` project alongside the main app project - (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists): entity model and query criteria (if - applicable) go in the `.Models` project (they're part of the API client contract other - services consume); the mapping and controller (if applicable) go in the main app project. - - **Single-project layout** (no `.Models` project): all files go in the one app project. -5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently — it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -6. **Existing conventions.** Skim one existing entity/mapping (and controller, if - applicable) triplet in the project (if any exist) for property style, nullable-reference - usage, and namespace layout, and match it. -7. **Every entity gets a generic controller — full stop, independent of whatever else exists for - it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** (see step 8 — that's a separate, - later check, not the thing that decides whether a controller gets made at all). When retrofitting - controllers onto entities that already exist: for each - entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) — every entity, not just the ones an - Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** — move on to the next entity. -8. **Only after every entity has its generic controller (step 7): does an Api Client already - define custom methods/requests targeting one of them?** Check `{TargetApp}.Models/Api/{ClientName}.cs` - and its `Api/Requests/` folder (per `nano-define-api-client`) for requests whose - `this.Controller` (explicit or inferred) points at an entity's controller. This check only adds - *extra* custom action stubs on top of the generic controller that step 7 already guarantees - exists — it never substitutes for it, and an entity with no matching Api Client requests still - gets its plain generic controller from step 7, nothing less. For each matching request found, - File 4 below scaffolds a matching controller action **stub** — signature, route, and - attributes, body `throw new NotImplementedException();` — not the real implementation. - Scaffolding the contract is this skill's job; implementing the actual business logic is not. - -## File 1 — Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's - existing convention (see step 5 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. -- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** An entity - marked `[Subscribe]` (AGENTS.md's `### Entity Events`) is a local replica kept in sync by the - built-in `EntityEventingHandler` whenever the publishing app's source entity changes — - update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP - surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its - controller (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be - exposed to callers on a subscribed entity. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 — Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout — mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly — including navigations and - collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, - convention-inferred relationship is exactly the kind of mistake that's invisible until it's a - production bug (e.g. EF silently creating a shadow FK column for a stray navigation property - with no real relationship behind it). Being explicit is what makes a mistake visible on read, - not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ - `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This - makes the mapping file scannable against the entity file side by side: a missing or - out-of-place property is immediately visible, not something that only surfaces when something - breaks at runtime. - - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` / `.HasMaxLength(n)` matching - the property's own nullability/attributes (`[MaxLength]` if present, or the property's - non-nullable reference-type status) — not just whatever EF would infer unprompted. - - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined - `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned - where the pair sits in the property order. - - **Inverse collection/reference navigation with no FK of its own** (the principal side of a - relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` - (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever - the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` - itself, that's declared once, on the dependent side. EF Core matches the two configurations by - navigation pairing and merges them as the same relationship, so writing it from both ends is - safe as long as they agree; it's what makes the relationship visible when scanning *this* - entity's mapping file alone, not just the other one. **No comment needed** — the - `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating - "FK owned/declared in the other file" for every single one of these is noise, not - information. - - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually - support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be — don't guess a - relationship that isn't in the model. If the user says to leave the property in place without - resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather - than leaving it for EF's convention to silently invent something. -- No registration step needed — Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity — check `Migrations/` for - precedent first). - -## File 3 — Query criteria (API/Web only — skip for Console) - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by — don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - — check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 — Controller (API/Web only — skip for Console) - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument — an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 5), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every - entity, with no exceptions other than `[Subscribe]`.** 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. -- No manual registration needed — Nano's MVC discovery picks up the controller - automatically from the assembly. - -### Custom action stubs (per step 8 above) - -For each matching Api Client request found: add one action to the controller, using the -request's own route constant (most real codebases define `public const string Route = "..."` -locally on the request class itself — reference it as `[Route(MyRequest.Route)]` rather than -retyping the literal, so the two sides can't drift) and the matching `[Http*]` verb attribute. -Match the doc-comment/`[ProducesResponseType]` conventions of whatever controllers already exist -in the project. The body is always a stub: - -```csharp -public virtual Task DoTheThingAsync(/* params matching the request */, CancellationToken cancellationToken = default) - // Implement. See Api.DoTheThingAsync's doc comment for the full contract. - => throw new NotImplementedException(); -``` - -- **Don't narrow the tier to dodge a collision — flag it instead.** The default tier (above) is - full CRUD regardless of what custom actions exist; if a custom request's route+verb is - literally identical to a route the generic tier also exposes (e.g. a custom - `[PostAction("create")]` alongside generic `POST .../create`), that's a genuine defect in the - Request-side contract (one of the two routes needs to change), not a reason to remove the - entity's generic capability. Add the custom action anyway, with a prominent comment naming - exactly which generic route it collides with (verb + path + which AGENTS.md table row), and - leave both in place for the user to resolve. -- **Check built-in routes too, not just the generic CRUD table** — a narrower base class can have - its own built-in action set with its own routes (e.g. `BaseEntityUserController`'s identity - actions, AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). - A custom request whose route matches one of those collides exactly the same way a generic CRUD - route would — flag it the same way. -- **If two *custom* requests collide with each other** (same controller, same route+verb): this is - a genuine defect in the Request-side contract, not something to silently rename or merge. - Scaffold both stubs anyway, with a prominent comment on each naming the other action it collides - with — flag it for the user to resolve (one of the two routes needs to change, or the two actions - need merging into one) rather than guessing. - -## After generating - -- Show the user the files generated and where they were placed (two for Console, four for - API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding - the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients/.claude/skills/nano-undefine-api-client/SKILL.md b/Api.ApiClients/.claude/skills/nano-undefine-api-client/SKILL.md deleted file mode 100644 index c1c27336..00000000 --- a/Api.ApiClients/.claude/skills/nano-undefine-api-client/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: nano-undefine-api-client -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition — the `BaseApiClient` subclass and/or its custom request -types — from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) — point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" — confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** — either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding — this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** — if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) — leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too — otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup — - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients/.github/copilot-instructions.md b/Api.ApiClients/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/Api.ApiClients/.github/copilot-instructions.md +++ b/Api.ApiClients/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.ApiClients/.github/prompts/nano-add-api-client.prompt.md b/Api.ApiClients/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/Api.ApiClients/.github/prompts/nano-add-api-client.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**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 -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -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 - 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`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `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, - 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 - 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 - 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 - 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 - - 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 - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't 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 -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -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. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- 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. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 a87c0d62..7ae3707e 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +193,89 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +301,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +325,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +376,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.ApiClients/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/Api.ApiClients/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml diff --git a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 ef032dc8..fecad9a6 100644 --- a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -88,10 +94,13 @@ 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. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,25 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +321,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +376,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +451,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +494,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients/.github/prompts/nano-add-event-handler.prompt.md b/Api.ApiClients/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.ApiClients/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/Api.ApiClients/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients/.github/prompts/nano-add-logging-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/Api.ApiClients/.github/prompts/nano-add-logging-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/Api.ApiClients/.github/prompts/nano-add-metrics.prompt.md b/Api.ApiClients/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/Api.ApiClients/.github/prompts/nano-add-metrics.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/Api.ApiClients/.github/prompts/nano-add-public-exposure.prompt.md b/Api.ApiClients/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..d7f6884f 100644 --- a/Api.ApiClients/.github/prompts/nano-add-public-exposure.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.ApiClients/.github/prompts/nano-add-startup-task.prompt.md b/Api.ApiClients/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/Api.ApiClients/.github/prompts/nano-add-startup-task.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/Api.ApiClients/.github/prompts/nano-add-storage-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..15048379 100644 --- a/Api.ApiClients/.github/prompts/nano-add-storage-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -154,22 +165,33 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +295,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +323,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.ApiClients/.github/prompts/nano-define-api-client.prompt.md b/Api.ApiClients/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/Api.ApiClients/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.ApiClients/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.ApiClients/.github/prompts/nano-remove-api-client.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-api-client.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.ApiClients/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.ApiClients/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.ApiClients/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.ApiClients/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's diff --git a/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.ApiClients/.github/prompts/nano-remove-data-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..91df5c29 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-data-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. diff --git a/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.ApiClients/.github/prompts/nano-remove-event-handler.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.ApiClients/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.ApiClients/.github/prompts/nano-remove-health-checks.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-health-checks.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/Api.ApiClients/.github/prompts/nano-remove-identity.prompt.md b/Api.ApiClients/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/Api.ApiClients/.github/prompts/nano-remove-identity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/Api.ApiClients/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-scaffold-custom-endpoint.prompt.md deleted file mode 100644 index c22a5b80..00000000 --- a/Api.ApiClients/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -mode: agent -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read -AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them -following this solution's own established conventions (one-liner XML doc summaries, -`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the -controller's own namespace). - -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. - -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. - ---- - -## Step 1 - Confirm a custom endpoint is actually needed - -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted -once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: - -- **Can the desired response be expressed as the target entity plus some of its navigation - properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the - needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher - to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. -- **Two real limits of that mechanism, either of which can still justify going custom even when the - shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which - tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but - never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; - a custom endpoint can. - - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation - genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size - concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped - custom endpoint instead of tagging it. -- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the - entity, responses composed from more than one unrelated entity graph, or actual business logic - beyond read/write. A representative case: an action that has to validate something (e.g. an - email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step - is expressible as a single generic `.Entity` call, and splitting them into two separate generic - calls from the caller would let the write happen without the validation ever running. This is - the right call for a custom endpoint, not a sign to keep looking for a generic-composition way - around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. -- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. -- **Is the actual need "the generic write plus an invariant that must always hold," not a new - route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect - after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a - case for **overriding the generic CRUD action** on the owning entity's own controller, not adding - a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action - instead of a new endpoint** below before scaffolding a new route for this. -- **Before designing a custom action (or a composition) around a delete or an update, check what - the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already - removes the dependent row(s) at the database level, so an explicit second delete call for that - child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable - (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL - `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse - gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic - Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child - collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade - already makes unnecessary, or assuming a collection reassignment does something it doesn't. - -If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a -custom action just because it was asked for without checking first. Note that adding a new -`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. - -## Step 2 - Public API or internal service? - -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this -wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: - -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own - `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing - it at all. Go to **Internal service path** below. - -## Step 3 - Pin down shape and conventions - -- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't - asked for or that don't match an existing sibling action's pattern in the same - controller/project. -- **Naming and location conventions.** Skim an existing custom action in the same controller (or a - sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an - established shape for this, don't invent a new one. - ---- - -## Shared DTO conventions - -Both paths below build request/response DTOs the same way - read this once, apply it wherever a -DTO comes up in either path: - -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a - request) only what the *caller* should be able to set, never fields that represent - internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even - if the controller action happens to build an entity from the request afterward. -- **Match validation attributes to what the underlying entity/write actually needs, not just - `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a - bad request is rejected by model binding before it ever reaches an Api Client call or a repository - write, instead of surfacing as a downstream 400/500. -- **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to - stitch together whatever the response needs before reaching for a custom Response DTO.** A custom - endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. -- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not - just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in - the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response - constructor/mapping actually walks and confirm every property on it is tagged before assuming - `[Include]` "already covers this." - ---- - -## Public API path - -The controller composes calls that already exist elsewhere - this path never defines a new Api -Client method of its own. - -### Does the backing call already exist? - -Check whether the Api Client(s) this action needs are already injected in this controller (or -injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. - -If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a -different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. - -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client -method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the -target's Api Client class that does nothing but forward to the generic method. This isn't limited -to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on -what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's -more than one line. A custom Api Client method should only exist when it's paired with a -controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped -or not, just hides what's actually happening for no benefit. - -**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely -to confirm the id exists before calling one is duplicated work the service already does. Only look -something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip -the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops -a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off -rather than a silent one. - -### Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the Public API's own app project - **this is a -Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -`[Required]` on anything that must be present; match the nullable-reference style already used by -sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else -belongs on this class. - -### Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. See **Shared DTO -conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. - -### Controller action - -Add to an existing Public API controller, or create a new one deriving from `BaseController` if no -suitable controller exists yet: - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Compose injected Api Client(s). - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - - match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it - calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action needs a piece of the caller's identity further downstream, **read it - here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim - from the JWT Nano forwards alongside the call. - ---- - -## Internal service path - -This action **is** a new piece of contract another application will call - scaffolding it means -scaffolding both halves together: the controller action, and the paired Api Client custom -request/method that lets other applications actually call it. - -### Does this app's own Api Client class exist yet? - -Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. - -### Shared body model - -If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** -the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind -to, not two separate DTOs kept in sync by hand: - -```csharp -public class -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a -`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't -come from the entity itself. - -### Api Client request and method - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body -model from above: - -```csharp -[PostAction(MyActionRoutes.MY_ACTION)] -public class MyActionRequest : BaseRequest -{ - [Body] - public virtual MyAction Model { get; set; } = null!; - - public MyActionRequest() - { - this.Controller = "MyEntities"; - } -} -``` - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response -genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity -controller that still returns that entity). Set `this.Controller` explicitly in the constructor -only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply. - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests -avoid drift exactly this way. - -Add the corresponding method to this app's own Api Client class: - -```csharp -public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) -{ - return this.InvokeAsync(new MyActionRequest - { - Model = model - }, cancellationToken); -} -``` - -One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no -response) or `this.InvokeAsync(request, cancellationToken)` (typed response). -Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. - -**If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for -`TResponse`, which for a collection response means `null` (not-found) is already distinguishable -from an empty collection (found, nothing to return) with no extra plumbing. Have the controller -action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. - -**The method's parameter is the shared body model itself, not its properties spread out as -separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body -model (previous section) is that it *is* the contract's shape; re-exploding it into scalar -parameters here just to reconstruct the same object one line later is pointless indirection, and -it makes the client method's signature drift from the model instead of just being it. Only -parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like -`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) -belong as their own parameter alongside the model. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded -token instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce -it, only document the expectation. - -### Controller action - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The . -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route(MyActionRoutes.MY_ACTION)] -[ProducesResponseType((int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) -{ - // Use IRepository/IEventing directly. - - return this.Ok(); -} -``` - -- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming - (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an - entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. -- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the - action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary - constructor's `repository`/`eventing` parameters are already passed to the base constructor: - referencing the same parameter again inside a method captures it a second time and is a compile - error (CS9107 - "captured into the state of the enclosing type and its value is also passed to - the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. -- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. -- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A - plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per - AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` - 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling - Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a - not-found case that specifically needs a message/code rather than a bare 404: - `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly - which generic route it collides with (verb + path + which AGENTS.md table row), and leave both - in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. - `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: - `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. -- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is - `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base - implementation, or reproduce its persistence step, then add the extra behavior) on the *same* - route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. - Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's - doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom - actions should still avoid the base routes entirely; don't reach for an override as a shortcut - to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. - -#### Overriding a generic CRUD action instead of a new endpoint - -The case above generalizes into a real alternative to scaffolding a new custom action: whenever -the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant -`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to -them. This enforces the rule as a property of the *entity's own controller*, so it holds for every -consumer, not just the one Public API that remembered to compose it. - -- **Cover every generic write variant the invariant must survive, not just the one your current - caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity - variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, - `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). - If the invariant genuinely must always hold, override all of the single-entity variants a caller - could plausibly reach; overriding only the one your current Public API calls leaves the same gap - a new custom endpoint would have needed to close anyway, just via a different route. -- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ - `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before - calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking - row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra - caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ - `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a - bespoke custom action could. If per this solution's convention a downstream service doesn't - parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a - generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform - tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a - scoped `QueryFirst`) before calling the generic write; the override and the Public API check are - complementary, not either-or. -- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row - 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 - non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of - `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another - entity's existence, check both directions before assuming only deletes need guarding. -- **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an - `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update - scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or - after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed - to. Overriding moves *where* this logic lives, not whether it's still needed. -- **Duplicate the validation across each overridden variant rather than extracting a shared private - helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block - repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. -- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the - error comes from a bespoke custom action or an overridden generic one. -- **If this action's route collides with another custom action's route** (same controller, same - route+verb): a genuine defect in the request-side contract, not something to silently rename or - merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims - from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already - supplied explicitly on the request, which is the normal case for an internal-service action's - own use of caller context. - ---- - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, - and the Api Client method) and which project each lives in. -- **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real - implementation too. -- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action - against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain - what already does the job instead of generating anything. diff --git a/Api.ApiClients/.github/prompts/nano-scaffold-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/Api.ApiClients/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 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. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients/.github/prompts/nano-undefine-api-client.prompt.md b/Api.ApiClients/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/Api.ApiClients/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 4255c02b..9da98eba 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -46,10 +46,12 @@ inside `{name}/`. | `{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. | | `.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/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. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -80,7 +82,7 @@ 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 `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +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, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -208,7 +210,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -369,6 +371,93 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for @@ -1467,6 +1556,92 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. @@ -1509,9 +1684,31 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1672,7 +1869,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1680,6 +1881,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1786,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -1879,7 +2100,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers @@ -3073,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler 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 f360b76d..561ff02c 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 @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,50 @@ 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 gotcha above, nothing gets registered even though the config exists. +## 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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + 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 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` + 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 confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 e4bbdd0f..417ed0cb 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 @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -148,10 +203,30 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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 @@ -303,6 +378,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. 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 f87cc760..531bfe11 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 @@ -353,10 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case (see -`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on -`TenantsController`, not a dedicated `TenantDomainsController`) — check this deliberately rather -than assuming inference works. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` 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 d4bb537d..6bb5bd37 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -265,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style - runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy - time — that's not this skill's job; the app has exactly one data provider, chosen once, here. - Add only the one migration step matching that provider, unconditionally. Don't add the other - two providers' steps as dormant `if:`-guarded alternatives — unreachable steps (and the - `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not - documentation, and a workflow file is not the place to leave every road not taken. If this is - *replacing* an existing provider, remove that provider's migration step (and any env vars only - it needed) rather than leaving it disabled alongside the new one. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. 2. **Migration step** — add the one step below matching the chosen provider, placed after `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index d7538302..994e8e3f 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 @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. - Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + (`StartsWith`/`Contains` 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. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-event-handler/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..f333eac3 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..34089aa4 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -154,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/Api.Auth.External.Custom/.claude/skills/nano-define-api-client/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-define-api-client/SKILL.md deleted file mode 100644 index d6eda4d7..00000000 --- a/Api.Auth.External.Custom/.claude/skills/nano-define-api-client/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: nano-define-api-client -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications — the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first — it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead — point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below — there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity — - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead — otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) — pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle — Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes — don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed — adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies — one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action — name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name — this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case — gateway -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) — so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side — per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly — defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT — don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` — the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed — the client class name and every custom method added — since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead — this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 1. **Workflow steps** — remove ` Database Migration` (this is the only migration step - present — `nano-add-data-provider` no longer adds the other two providers' steps as dormant - `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds together for that provider). 2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — - `nano-add-data-provider` no longer adds one). Only remove + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/Api.Auth.External.Custom/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.Auth.External.Custom/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md deleted file mode 100644 index 0433d19f..00000000 --- a/Api.Auth.External.Custom/.claude/skills/nano-scaffold-custom-endpoint/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: nano-scaffold-custom-endpoint -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - controller action, Request/Response DTOs, and (when the endpoint composes another Nano application) the backing Api Client method - following Nano framework and this solution's established conventions. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover — the -controller action, its `Request`/`Response` DTOs, and, if the action needs to call another Nano -application, the Api Client call that backs it. Read AGENTS.md's `### Controllers` and -`### Api Clients` sections first; this skill does not repeat those, only how to combine them for -one custom action following this solution's own established conventions (the pattern built out -across `Api.Platform`/`Api.Admin` this session: one-liner XML doc summaries, `[ProducesResponseType]` -per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). - -## Before generating anything, determine - -1. **Is this actually custom?** Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a - custom endpoint is only warranted if a single call can't compose the existing generic - `.Entity`/`.Auth`/`.Audit`/`.Identity` methods (plus `[Include]`d reads) and query criteria - can't express the filter. If the generic surface already covers this, say so and point the - user at that instead of scaffolding something redundant — don't build a custom action just - because it was asked for without checking first. -2. **Which kind of controller is this?** The shape of everything below depends on this, and it's - not always obvious from the request alone: - - **Gateway controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or - a custom client method) into one response; it has no `IRepository` of its own. - - **Internal service controller** — the action implements logic directly against this app's - own `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity - backing it at all. - If the target app/controller isn't named, or it's not clear from context which kind applies, - **ask** — don't default to one. Getting this wrong means the wrong constructor, the wrong - dependency, and a DTO shape aimed at the wrong layer. -3. **Endpoint shape.** HTTP verb, route segment, input shape (route/query/body parameters), and - response shape. Ask for whatever isn't already given — don't invent fields, routes, or status - codes that weren't asked for or that don't match an existing sibling action's pattern in the - same controller/project. -4. **Does the backing call already exist?** For a gateway controller, check whether the Api - Client(s) it needs are already injected in this controller (or injectable without issue) and - whether the specific call needed is already a generic method or an existing custom method. - - If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the - user whether to create it now** (that's `nano-define-api-client`'s job, in the *owning* - service's own project — a different application than this controller likely lives in). - Don't invoke that skill automatically, and don't scaffold this action against a method that - doesn't exist yet as if it already does. Proceed with this skill only once the user has - confirmed whether/how that gets created. If that new custom request ends up without a - response, or its response doesn't resolve (by pluralized name) to the controller the action - actually lives on — the common case for a gateway/cross-service custom endpoint, whose - response is often a bespoke DTO or which piggy-backs on a controller unrelated to the - response's own name — `nano-define-api-client` must set `this.Controller` explicitly in - that request's constructor; don't assume Nano's route-inference will find the right - controller on its own. -5. **Naming and location conventions.** Skim an existing custom action in the same controller (or - a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has - an established shape for this (see `Api.Platform`/`Api.Admin`'s `AccountsController`, - `TenantsController`, etc.), don't invent a new one. - -## Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the controller's own app project — **this is -a gateway-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even when an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -- Only include properties the endpoint actually needs — no speculative fields. -- `[Required]` on anything that must be present; match the nullable-reference style already used - by sibling `Request` classes in the same project. - -## Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the client needs — not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. - -## Controller action - -Add to an existing controller, or create a new one deriving from `BaseController` (gateway) or -the appropriate entity controller base (internal service, per AGENTS.md's Entity controller -hierarchy) if no suitable controller exists yet — follow `nano-scaffold-entity`'s controller-file -conventions for a brand new controller's shape/naming. - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Gateway: compose injected Api Client(s). - // Internal service: use IRepository/IEventing directly. - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - — match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if - it calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment, per the pattern used earlier this - session for pre-auth service calls. -- **Route constant sharing** applies only when this controller is itself the *target* of another - application's Api Client call (i.e., this is an internal service's real controller, not a - gateway) — in that case, define the route segment as a constant in `{Name}.Models/Consts/` - (this project's existing convention — see e.g. `Svc.Accounts.Models/Consts/`) and reference it - from both this `[Route(...)]` and the calling side's custom request's action attribute, per - AGENTS.md. A gateway controller with no Api Client pointed at it has no such constant to - share. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action (gateway or internal-service) needs a piece of the caller's identity - further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` - and pass it explicitly as a field on the outgoing custom request — don't rely on the target - service re-extracting the same claim from the JWT Nano forwards alongside the call. This keeps - claim-parsing in one place and means the target doesn't need a real, matching tenant behind the - forwarded token just to exercise the endpoint in `Development`. - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and — if applicable — the Api Client method it calls) and which project each lives in. -- If step 4 surfaced a missing custom Api Client method the user hasn't decided on yet, that's - the natural stopping point — don't scaffold the controller action against a call that doesn't - exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response — explain - what already does the job instead of generating anything. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-scaffold-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-scaffold-entity/SKILL.md deleted file mode 100644 index dcfe201b..00000000 --- a/Api.Auth.External.Custom/.claude/skills/nano-scaffold-entity/SKILL.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -name: nano-scaffold-entity -description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). ---- - -# Nano entity scaffold - -Generates the files Nano needs for a new entity: data model and EF Core mapping always; query -criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read -`AGENTS.md` in the target repo root first if present — it documents the exact base classes and -gotchas for that specific solution; this skill assumes the general Nano.Library conventions and -defers to a project's own AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). - An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context — with no Data provider, the generated files would be dead code - with nothing to persist them. If none is registered, stop and tell the user a Data provider - needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request — need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication`. - - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 - entirely (query criteria and controllers are API-request concepts; a Console app has - nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app — otherwise just skip silently; a Console - app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never - `Controllers/`/`Criterias/`. -4. **Project layout.** Look for a `.Models` project alongside the main app project - (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists): entity model and query criteria (if - applicable) go in the `.Models` project (they're part of the API client contract other - services consume); the mapping and controller (if applicable) go in the main app project. - - **Single-project layout** (no `.Models` project): all files go in the one app project. -5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently — it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -6. **Existing conventions.** Skim one existing entity/mapping (and controller, if - applicable) triplet in the project (if any exist) for property style, nullable-reference - usage, and namespace layout, and match it. -7. **Every entity gets a generic controller — full stop, independent of whatever else exists for - it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** (see step 8 — that's a separate, - later check, not the thing that decides whether a controller gets made at all). When retrofitting - controllers onto entities that already exist: for each - entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) — every entity, not just the ones an - Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** — move on to the next entity. -8. **Only after every entity has its generic controller (step 7): does an Api Client already - define custom methods/requests targeting one of them?** Check `{TargetApp}.Models/Api/{ClientName}.cs` - and its `Api/Requests/` folder (per `nano-define-api-client`) for requests whose - `this.Controller` (explicit or inferred) points at an entity's controller. This check only adds - *extra* custom action stubs on top of the generic controller that step 7 already guarantees - exists — it never substitutes for it, and an entity with no matching Api Client requests still - gets its plain generic controller from step 7, nothing less. For each matching request found, - File 4 below scaffolds a matching controller action **stub** — signature, route, and - attributes, body `throw new NotImplementedException();` — not the real implementation. - Scaffolding the contract is this skill's job; implementing the actual business logic is not. - -## File 1 — Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's - existing convention (see step 5 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request - implies one of these rather than full CRUD. -- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** An entity - marked `[Subscribe]` (AGENTS.md's `### Entity Events`) is a local replica kept in sync by the - built-in `EntityEventingHandler` whenever the publishing app's source entity changes — - update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP - surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its - controller (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be - exposed to callers on a subscribed entity. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 — Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout — mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly — including navigations and - collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, - convention-inferred relationship is exactly the kind of mistake that's invisible until it's a - production bug (e.g. EF silently creating a shadow FK column for a stray navigation property - with no real relationship behind it). Being explicit is what makes a mistake visible on read, - not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ - `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This - makes the mapping file scannable against the entity file side by side: a missing or - out-of-place property is immediately visible, not something that only surfaces when something - breaks at runtime. - - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` / `.HasMaxLength(n)` matching - the property's own nullability/attributes (`[MaxLength]` if present, or the property's - non-nullable reference-type status) — not just whatever EF would infer unprompted. - - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined - `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned - where the pair sits in the property order. - - **Inverse collection/reference navigation with no FK of its own** (the principal side of a - relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` - (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever - the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` - itself, that's declared once, on the dependent side. EF Core matches the two configurations by - navigation pairing and merges them as the same relationship, so writing it from both ends is - safe as long as they agree; it's what makes the relationship visible when scanning *this* - entity's mapping file alone, not just the other one. **No comment needed** — the - `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating - "FK owned/declared in the other file" for every single one of these is noise, not - information. - - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually - support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be — don't guess a - relationship that isn't in the model. If the user says to leave the property in place without - resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather - than leaving it for EF's convention to silently invent something. -- No registration step needed — Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity — check `Migrations/` for - precedent first). - -## File 3 — Query criteria (API/Web only — skip for Console) - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by — don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - — check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 — Controller (API/Web only — skip for Console) - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument — an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 5), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every - entity, with no exceptions other than `[Subscribe]`.** 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. -- No manual registration needed — Nano's MVC discovery picks up the controller - automatically from the assembly. - -### Custom action stubs (per step 8 above) - -For each matching Api Client request found: add one action to the controller, using the -request's own route constant (most real codebases define `public const string Route = "..."` -locally on the request class itself — reference it as `[Route(MyRequest.Route)]` rather than -retyping the literal, so the two sides can't drift) and the matching `[Http*]` verb attribute. -Match the doc-comment/`[ProducesResponseType]` conventions of whatever controllers already exist -in the project. The body is always a stub: - -```csharp -public virtual Task DoTheThingAsync(/* params matching the request */, CancellationToken cancellationToken = default) - // Implement. See Api.DoTheThingAsync's doc comment for the full contract. - => throw new NotImplementedException(); -``` - -- **Don't narrow the tier to dodge a collision — flag it instead.** The default tier (above) is - full CRUD regardless of what custom actions exist; if a custom request's route+verb is - literally identical to a route the generic tier also exposes (e.g. a custom - `[PostAction("create")]` alongside generic `POST .../create`), that's a genuine defect in the - Request-side contract (one of the two routes needs to change), not a reason to remove the - entity's generic capability. Add the custom action anyway, with a prominent comment naming - exactly which generic route it collides with (verb + path + which AGENTS.md table row), and - leave both in place for the user to resolve. -- **Check built-in routes too, not just the generic CRUD table** — a narrower base class can have - its own built-in action set with its own routes (e.g. `BaseEntityUserController`'s identity - actions, AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). - A custom request whose route matches one of those collides exactly the same way a generic CRUD - route would — flag it the same way. -- **If two *custom* requests collide with each other** (same controller, same route+verb): this is - a genuine defect in the Request-side contract, not something to silently rename or merge. - Scaffold both stubs anyway, with a prominent comment on each naming the other action it collides - with — flag it for the user to resolve (one of the two routes needs to change, or the two actions - need merging into one) rather than guessing. - -## After generating - -- Show the user the files generated and where they were placed (two for Console, four for - API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding - the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-undefine-api-client/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-undefine-api-client/SKILL.md deleted file mode 100644 index c1c27336..00000000 --- a/Api.Auth.External.Custom/.claude/skills/nano-undefine-api-client/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: nano-undefine-api-client -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition — the `BaseApiClient` subclass and/or its custom request -types — from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) — point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" — confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** — either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding — this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** — if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) — leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too — otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup — - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.Auth.External.Custom/.github/copilot-instructions.md b/Api.Auth.External.Custom/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/Api.Auth.External.Custom/.github/copilot-instructions.md +++ b/Api.Auth.External.Custom/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-api-client.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**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 -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -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 - 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`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `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, - 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 - 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 - 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 - 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 - - 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 - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't 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 -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -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. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- 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. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. 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 a87c0d62..7ae3707e 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 @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,51 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +193,89 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +301,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +325,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +376,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it 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 new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml 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 new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 ef032dc8..fecad9a6 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 @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +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. @@ -88,10 +94,13 @@ 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. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,25 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +321,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +376,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +451,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +494,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at 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 new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-event-handler.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-logging-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-logging-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-metrics.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-metrics.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-public-exposure.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..d7f6884f 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-public-exposure.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,8 +174,18 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-startup-task.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-startup-task.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-storage-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..15048379 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-storage-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -98,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -154,22 +165,33 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +295,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +323,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-define-api-client.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/Api.Auth.External.Custom/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's 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 new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-data-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..91df5c29 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-data-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. 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 new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-event-handler.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-health-checks.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-health-checks.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/Api.Auth.External.Custom/.github/prompts/nano-remove-identity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-remove-identity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-scaffold-custom-endpoint.prompt.md deleted file mode 100644 index c22a5b80..00000000 --- a/Api.Auth.External.Custom/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ /dev/null @@ -1,569 +0,0 @@ ---- -mode: agent -description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. ---- - -# Nano scaffold custom endpoint - -Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read -AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them -following this solution's own established conventions (one-liner XML doc summaries, -`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the -controller's own namespace). - -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. - -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. - ---- - -## Step 1 - Confirm a custom endpoint is actually needed - -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted -once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: - -- **Can the desired response be expressed as the target entity plus some of its navigation - properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the - needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher - to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. -- **Two real limits of that mechanism, either of which can still justify going custom even when the - shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which - tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but - never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; - a custom endpoint can. - - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation - genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size - concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped - custom endpoint instead of tagging it. -- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the - entity, responses composed from more than one unrelated entity graph, or actual business logic - beyond read/write. A representative case: an action that has to validate something (e.g. an - email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step - is expressible as a single generic `.Entity` call, and splitting them into two separate generic - calls from the caller would let the write happen without the validation ever running. This is - the right call for a custom endpoint, not a sign to keep looking for a generic-composition way - around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. -- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. -- **Is the actual need "the generic write plus an invariant that must always hold," not a new - route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect - after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a - case for **overriding the generic CRUD action** on the owning entity's own controller, not adding - a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action - instead of a new endpoint** below before scaffolding a new route for this. -- **Before designing a custom action (or a composition) around a delete or an update, check what - the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already - removes the dependent row(s) at the database level, so an explicit second delete call for that - child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable - (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL - `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse - gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic - Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child - collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade - already makes unnecessary, or assuming a collection reassignment does something it doesn't. - -If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a -custom action just because it was asked for without checking first. Note that adding a new -`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. - -## Step 2 - Public API or internal service? - -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this -wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: - -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own - `IRepository`/`IEventing`, either as a custom method on an existing entity controller - (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing - it at all. Go to **Internal service path** below. - -## Step 3 - Pin down shape and conventions - -- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't - asked for or that don't match an existing sibling action's pattern in the same - controller/project. -- **Naming and location conventions.** Skim an existing custom action in the same controller (or a - sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an - established shape for this, don't invent a new one. - ---- - -## Shared DTO conventions - -Both paths below build request/response DTOs the same way - read this once, apply it wherever a -DTO comes up in either path: - -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a - request) only what the *caller* should be able to set, never fields that represent - internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even - if the controller action happens to build an entity from the request afterward. -- **Match validation attributes to what the underlying entity/write actually needs, not just - `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a - bad request is rejected by model binding before it ever reaches an Api Client call or a repository - write, instead of surfacing as a downstream 400/500. -- **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to - stitch together whatever the response needs before reaching for a custom Response DTO.** A custom - endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. -- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not - just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in - the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response - constructor/mapping actually walks and confirm every property on it is tagged before assuming - `[Include]` "already covers this." - ---- - -## Public API path - -The controller composes calls that already exist elsewhere - this path never defines a new Api -Client method of its own. - -### Does the backing call already exist? - -Check whether the Api Client(s) this action needs are already injected in this controller (or -injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. - -If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a -different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. - -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client -method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the -target's Api Client class that does nothing but forward to the generic method. This isn't limited -to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on -what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's -more than one line. A custom Api Client method should only exist when it's paired with a -controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped -or not, just hides what's actually happening for no benefit. - -**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely -to confirm the id exists before calling one is duplicated work the service already does. Only look -something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip -the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops -a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off -rather than a silent one. - -### Request DTO (if the action takes parameters) - -Location: `Requests//Request.cs` in the Public API's own app project - **this is a -Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api -Client call happens inside the same action. - -```csharp -public class Request -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -`[Required]` on anything that must be present; match the nullable-reference style already used by -sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else -belongs on this class. - -### Response DTO (if the action returns a body) - -Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity -unless that's genuinely what the sibling conventions in this project do. See **Shared DTO -conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. - -### Controller action - -Add to an existing Public API controller, or create a new one deriving from `BaseController` if no -suitable controller exists yet: - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The request. -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route("")] -[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) -{ - // Compose injected Api Client(s). - - return this.Ok(response); -} -``` - -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - - match what sibling actions in the same controller declare, don't pad the list. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it - calls another application's endpoint in that state, that target endpoint must itself be - `[AllowAnonymous]`; note that requirement in a comment. -- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding - caveat: if this action needs a piece of the caller's identity further downstream, **read it - here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim - from the JWT Nano forwards alongside the call. - ---- - -## Internal service path - -This action **is** a new piece of contract another application will call - scaffolding it means -scaffolding both halves together: the controller action, and the paired Api Client custom -request/method that lets other applications actually call it. - -### Does this app's own Api Client class exist yet? - -Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. - -### Shared body model - -If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** -the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind -to, not two separate DTOs kept in sync by hand: - -```csharp -public class -{ - [Required] - public virtual { get; set; } = ...; -} -``` - -See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a -`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't -come from the entity itself. - -### Api Client request and method - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body -model from above: - -```csharp -[PostAction(MyActionRoutes.MY_ACTION)] -public class MyActionRequest : BaseRequest -{ - [Body] - public virtual MyAction Model { get; set; } = null!; - - public MyActionRequest() - { - this.Controller = "MyEntities"; - } -} -``` - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response -genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity -controller that still returns that entity). Set `this.Controller` explicitly in the constructor -only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply. - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests -avoid drift exactly this way. - -Add the corresponding method to this app's own Api Client class: - -```csharp -public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) -{ - return this.InvokeAsync(new MyActionRequest - { - Model = model - }, cancellationToken); -} -``` - -One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no -response) or `this.InvokeAsync(request, cancellationToken)` (typed response). -Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. - -**If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for -`TResponse`, which for a collection response means `null` (not-found) is already distinguishable -from an empty collection (found, nothing to return) with no extra plumbing. Have the controller -action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. - -**The method's parameter is the shared body model itself, not its properties spread out as -separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body -model (previous section) is that it *is* the contract's shape; re-exploding it into scalar -parameters here just to reconstruct the same object one line later is pointless indirection, and -it makes the client method's signature drift from the model instead of just being it. Only -parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like -`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) -belong as their own parameter alongside the model. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded -token instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce -it, only document the expectation. - -### Controller action - -```csharp -/// -/// One-line summary of what this action does. -/// -/// The . -/// The cancellation token. -/// The response. -/// OK. -/// Bad Request. -/// Unauthorized. -/// Error occurred. -[HttpPost] -[Route(MyActionRoutes.MY_ACTION)] -[ProducesResponseType((int)HttpStatusCode.OK)] -[ProducesResponseType((int)HttpStatusCode.Unauthorized)] -[ProducesResponseType((int)HttpStatusCode.BadRequest)] -[ProducesResponseType((int)HttpStatusCode.InternalServerError)] -public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) -{ - // Use IRepository/IEventing directly. - - return this.Ok(); -} -``` - -- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming - (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an - entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. -- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the - action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary - constructor's `repository`/`eventing` parameters are already passed to the base constructor: - referencing the same parameter again inside a method captures it a second time and is a compile - error (CS9107 - "captured into the state of the enclosing type and its value is also passed to - the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. -- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. -- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A - plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per - AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` - 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling - Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a - not-found case that specifically needs a message/code rather than a bare 404: - `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly - which generic route it collides with (verb + path + which AGENTS.md table row), and leave both - in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. - `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: - `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. -- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is - `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base - implementation, or reproduce its persistence step, then add the extra behavior) on the *same* - route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. - Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's - doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom - actions should still avoid the base routes entirely; don't reach for an override as a shortcut - to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. - -#### Overriding a generic CRUD action instead of a new endpoint - -The case above generalizes into a real alternative to scaffolding a new custom action: whenever -the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant -`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to -them. This enforces the rule as a property of the *entity's own controller*, so it holds for every -consumer, not just the one Public API that remembered to compose it. - -- **Cover every generic write variant the invariant must survive, not just the one your current - caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity - variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, - `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). - If the invariant genuinely must always hold, override all of the single-entity variants a caller - could plausibly reach; overriding only the one your current Public API calls leaves the same gap - a new custom endpoint would have needed to close anyway, just via a different route. -- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ - `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before - calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking - row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra - caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ - `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a - bespoke custom action could. If per this solution's convention a downstream service doesn't - parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a - generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform - tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a - scoped `QueryFirst`) before calling the generic write; the override and the Public API check are - complementary, not either-or. -- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row - 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 - non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of - `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another - entity's existence, check both directions before assuming only deletes need guarding. -- **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an - `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update - scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or - after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed - to. Overriding moves *where* this logic lives, not whether it's still needed. -- **Duplicate the validation across each overridden variant rather than extracting a shared private - helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block - repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. -- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the - error comes from a bespoke custom action or an overridden generic one. -- **If this action's route collides with another custom action's route** (same controller, same - route+verb): a genuine defect in the request-side contract, not something to silently rename or - merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims - from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already - supplied explicitly on the request, which is the normal case for an internal-service action's - own use of caller context. - ---- - -## After generating - -- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, - and the Api Client method) and which project each lives in. -- **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real - implementation too. -- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action - against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain - what already does the job instead of generating anything. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-scaffold-entity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/Api.Auth.External.Custom/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 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. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-undefine-api-client.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/Api.Auth.External.Custom/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 4255c02b..9da98eba 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -46,10 +46,12 @@ inside `{name}/`. | `{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. | | `.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/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. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -80,7 +82,7 @@ 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 `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +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, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -208,7 +210,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -369,6 +371,93 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for @@ -1467,6 +1556,92 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. @@ -1509,9 +1684,31 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1672,7 +1869,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1680,6 +1881,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1786,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -1879,7 +2100,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers @@ -3073,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler 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 new file mode 100644 index 00000000..561ff02c --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -0,0 +1,180 @@ +--- +name: nano-add-api-client-configuration +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 — + 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 + 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 + 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 — + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app — the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client/SKILL.md new file mode 100644 index 00000000..c876fd9c --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-api-client/SKILL.md @@ -0,0 +1,69 @@ +--- +name: nano-add-api-client +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. +--- + +# Nano add API client + +Creates the bare typed HTTP client class a Nano application exposes to *other* applications — the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods — a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first — it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead — point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path — point them there instead of doing it here. + +## Before making any change, determine + +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do — say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity — unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead — otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) — pick something unambiguous and stable; + renaming it later breaks every consumer's config. + +## Client class + +`{ThisApp}.Models/Api/{ClientName}.cs`: + +```csharp +// Bare pass-through — no custom methods, relies entirely on .Entity/.Auth/.Audit +public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); +``` +```csharp +// Identity-backed — adds the .Identity method group for every consumer +public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); +``` + +Nothing else goes in this class as part of this skill — no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. + +## After making the change + +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead — this skill only produces the + bare class. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-apikey/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-apikey/SKILL.md new file mode 100644 index 00000000..4cfc9bd5 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -0,0 +1,113 @@ +--- +name: nano-add-authentication-apikey +description: Configure Nano's built-in API-key authentication (Data:Identity:ApiKey) on a Nano.Library-based API/Web application that already has Identity registered - works standalone, with no JWT/App:Authentication involved, or layered on top of an existing nano-add-authentication-jwt setup. Use when the user asks to add API-key authentication, an X-Api-Key header scheme, or machine-to-machine auth to a Nano API or Web application. +--- + +# Nano add API-key authentication + +Configures Nano's built-in API-key authentication on an existing Nano API/Web application. Read +`AGENTS.md`'s `#### Identity` and `#### Authentication` sections first — `ApiKey.Secret` lives +under `Data:Identity`, but its actual authentication behavior is documented in `Authentication`. + +**API-key auth does not require JWT.** This is the key thing that distinguishes it from +`nano-add-authentication-jwt`, and the reason it's a separate skill: per Nano's own +`AddNanoAuthentication` registration logic, the default scheme is chosen from +`(Jwt configured, ApiKeyOptions configured)` — `(false, true)` selects API-key-only. In that mode +there is **no `AuthController`** (it requires `IAuthRepository`, which is only registered when +`Jwt != null` — adding the controller without `Jwt` would fail DI resolution) and **no login +endpoint** — `ApiKeyAuthenticationHandler` validates the `X-Api-Key` header directly against the +identity store on every single request, with no token step at all. + +## Before making any change, determine + +1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and + `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an + identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + If so, say so and stop. +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, or an existing `AuthController`. + - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no + `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a + controller "just in case" — it would crash DI (see above). + - **Already configured** (`nano-add-authentication-jwt` already ran) — this app moves from + JWT-only to `JWT_OR_APIKEY` **automatically, from config alone**, nothing to change in the + existing `AuthController`. Its already-existing `LogInApiKeyAsync` action (visibility gated + purely on `Data:Identity:ApiKey:Secret` being set, per `ConditionalActionsConvention`) + becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the + user this new endpoint just appeared, it's a real behavior change on an app that may already + have callers, not just an implementation detail. +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case + the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific + for this skill to gate on beyond that. + +## appsettings.json + +Base `appsettings.json`: add `Data:Identity:ApiKey:Secret: null` (sibling of the rest of +`Identity` — see `nano-add-identity`). No local Development value needed by default — API keys +are normally created per-user via the identity-management endpoints +(`{id}/api-keys/create`, per AGENTS.md's `#### Identity user controller` table) rather than +hardcoded, unlike the shared JWT Development key pair. If the user wants a fixed key for local +testing convenience, set one in `appsettings.Development.json` instead of the base file. + +## Kubernetes / GitHub Actions (Staging/Production) + +1. **Workflow env var**: + ```yaml + AUTH_API_KEY_SECRET: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_API_KEY_SECRET || secrets.STAGING_AUTH_API_KEY_SECRET }} + ``` +2. **`.kubernetes/auth-api-key-secret.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: auth-api-key-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + apikey-secret: %AUTH_API_KEY_SECRET% + ``` + Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | + kubectl apply` pattern as every other secret — before `deployment.yaml`/`stateful-set.yaml`. + Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services — every app + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) — new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. +3. **`.kubernetes/deployment.yaml`** env entry: + ```yaml + - name: Data__Identity__ApiKey__Secret + valueFrom: + secretKeyRef: + name: auth-api-key-secret + key: apikey-secret + ``` + The env var name has a trailing `__Secret` — verify against an existing `deployment.yaml` in + the project if one has this wired already; a stale Lessons README once dropped that suffix, + so don't copy it from documentation without cross-checking a real manifest. + +## After making the change + +- Show the user every file touched. +- State plainly which mode this app ended up in — pure API-key (no controller, no login step) or + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this + implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. +- If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole + response. 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 new file mode 100644 index 00000000..417ed0cb --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -0,0 +1,395 @@ +--- +name: nano-add-authentication-jwt +description: Configure Nano's built-in JWT authentication (App:Authentication:Jwt) on a Nano.Library-based API/Web application - adds the Jwt configuration, the AuthController, the Development key setup, and (for the token-issuing app) the Staging/Production key-generation and Kubernetes secret. Use when the user asks to add login, sign-in, or JWT authentication to a Nano API or Web application - not for adding a user store by itself (that's nano-add-identity) or for API-key authentication by itself (that's nano-add-authentication-apikey, which works standalone without any of this). +--- + +# Nano add JWT authentication + +Configures Nano's built-in JWT authentication on an existing Nano API/Web application. Read +`AGENTS.md`'s `#### Authentication` section first — it documents the full `Configuration` table, +the `AuthController`'s sub-repository table, and the persistent-vs-transient distinction in +detail; this skill does not repeat that, only how to apply it. + +**This skill is JWT-specific.** API-key authentication is a genuinely independent auth mode in +Nano — it does not require `Jwt` at all, has no `AuthController`, and is `nano-add-authentication-apikey`'s +job, not this one. See step 6 below for what happens when both are configured on the same app. + +**No `Program.cs` registration call.** Unlike every other add-provider skill, Authentication is +pure config plus one controller — `IAuthRepository`'s sub-repositories self-populate based on +whichever config sections exist (`Jwt.RootLogin` → root login, [Identity](nano-add-identity) → +persistent login, `Jwt.ExternalLogins` with no Identity → transient login). There's nothing to +add to `.ConfigureServices(...)`. + +**Two request shapes.** A request naming this skill is one of: +1. **Persistent auth** — `Jwt` config + `AuthController` on top of already-configured Identity. +2. **Transient auth** — `Jwt` config + `AuthController`, but with `Jwt.ExternalLogins` instead of + Identity. +Figure out which one applies before touching anything — steps 1–5 below are how. Either can be +layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill — see +step 6. + +These two aren't the only combination — `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case — not repeated here. + +## Before making any change, determine + +1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. + - **Issuer**: needs both `PublicKey` and `PrivateKey` in Staging/Production, and creates the + `auth-jwt-secret` Kubernetes secret from real GitHub secrets. + - **Validator-only**: needs only `PublicKey` in Staging/Production, and must **not** create or + re-apply the secret — it references the one the issuer app already created. See the + Kubernetes section below; getting this backwards silently corrupts the shared secret with + unexpanded placeholder values — a real bug found and fixed this way in this codebase, so + don't repeat it. + - This distinction **does not apply to Development** — see below. +2. **Persistent or transient auth?** Check whether [Identity](nano-add-identity) (`Data:Identity`) + is already configured. + - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs + `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. + - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ + Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. + - If the user wants persistent auth but Identity isn't registered yet, stop and point them at + `nano-add-identity` first. +3. **Is Authentication already configured?** Check the base `appsettings.json` for + `App:Authentication:Jwt`, or an existing `AuthController`. If present, say so before changing + anything. +4. **Application type.** The `AuthController` is API/Web only — a Console app has no HTTP surface + to expose it on. A Console app can still have `Jwt` configured, but only for its own outbound + Api Client authentication (`Apis:{Client}:LogInRoot`, a different, already-documented AGENTS.md + concern) — not something this skill scaffolds a controller for. +5. **Is this a Console app whose only use of `Jwt` is outbound Api Client auth?** Skip the + controller step below in that case, per step 4. +6. **Is API-key authentication already configured** (`Data:Identity:ApiKey:Secret` set)? Check + the base `appsettings.json`. If so, this app was previously in pure API-key-only mode — no + `Jwt`, no `AuthController` (see `nano-add-authentication-apikey`: that controller would fail to resolve + `IAuthRepository` without `Jwt` configured, so it genuinely didn't exist yet). Adding `Jwt` now + changes two things **automatically, from config alone** — nothing extra to build, but tell the + user about both: + - Nano's scheme selection (`AddNanoAuthentication`, based on whether `Jwt`/`ApiKeyOptions` are + each present) switches from API-key-only to `JWT_OR_APIKEY` — existing API-key callers keep + working unchanged, requests carrying a JWT `Authorization` header now also work. + - The `AuthController` this skill adds will immediately expose `/auth/login/apikey` (its + visibility is gated purely on `Data:Identity:ApiKey:Secret` being set, per + `ConditionalActionsConvention`) — callers can now trade an API key for a JWT once instead of + presenting the key on every request. + +## appsettings.json — Jwt + +Base `appsettings.json` (sibling of `App:Version`/`App:Hosting`, per AGENTS.md's `##### Configuration` +example): `Issuer`/`Audience`/`PublicKey`/`PrivateKey` all `null`, `Expiration`/`RefreshExpiration` +at their framework defaults (`01:00:00`/`72:00:00`). Leave `RootLogin`/`ExternalLogins` out +entirely unless configuring them now — they're opt-in additions, not blank placeholders. + +**`appsettings.Development.json` — use the existing shared key pair, don't generate a new one.** +Every app in this codebase (issuers and validators alike) uses the exact same hardcoded RSA key +pair locally: + +```json +"App": { + "Authentication": { + "Jwt": { + "Issuer": "nano.development", + "Audience": "nano.development", + "PublicKey": "MIIBCgKCAQEAv7iVNUS5wT7Fvg/hkmlvvPnOW7Rcyh7dFStJSTtM+7f74+GGVJLl6spXasnsQ7v6rw7vlyb+uVk1UaQsUA38luSNGWfPqc3JAtkeJPWCu1kN79Yo3im7Qx6B1u4gf0AR3n86ClQGz3O5Jxo8M3+zlwveYnlf6bqhBakOVdPS5tX0Bvh/F9lXiEF53EZEcfuHjBjDLik9PUdTjqehPLCPyI1/FbfE8P1Y4S7AEfs2fIqXGxJNXDyoDRvi42qefqXcsmzBUDYtHqvwSHWcDn5DXDRY2FYkyESMvd7RRGwI6U0g8V9k3Qudd4LjQTs8LdBu5u25wvqx37Y1518BPqGQkQIDAQAB", + "PrivateKey": "MIIEowIBAAKCAQEAv7iVNUS5wT7Fvg/hkmlvvPnOW7Rcyh7dFStJSTtM+7f74+GGVJLl6spXasnsQ7v6rw7vlyb+uVk1UaQsUA38luSNGWfPqc3JAtkeJPWCu1kN79Yo3im7Qx6B1u4gf0AR3n86ClQGz3O5Jxo8M3+zlwveYnlf6bqhBakOVdPS5tX0Bvh/F9lXiEF53EZEcfuHjBjDLik9PUdTjqehPLCPyI1/FbfE8P1Y4S7AEfs2fIqXGxJNXDyoDRvi42qefqXcsmzBUDYtHqvwSHWcDn5DXDRY2FYkyESMvd7RRGwI6U0g8V9k3Qudd4LjQTs8LdBu5u25wvqx37Y1518BPqGQkQIDAQABAoIBAEwNH3sS+RCUIwLC7/sRQhbXjSlJgalX1uFH23lmQaJ0mEIMOyofX37kpwqgcM1pqwZ4SUhPWqoRnhn1ovJaqgD9Ro92Y6T7EarEj7Wfgi1pJSMnc+y05yi32E93BIMV2kDFfTONo2n1gNPnD0xqcsYPGjc76HUh6DADoMEhFr8kHaz4J2daKV0tJjApNt2oabk8BLQEq9Uv22DsLfL+nEOHPhSMk7EmNv3QQgUNH5ugeDNfTNr+A6K8YMbVVrmDalZS/GBWSscnJ9Ma2WrHJ/x2IRQECVMf6U05vrgtKb9imPcN09ccItIzcK/8ZBbSw2v+Gzf1Je447SYT9njAOiUCgYEAzOAsty4cxCLSWt2GBTE58MoThNeiVRBvc6Gw5B1olCCnWkVxRDYwYlPnwvemqa+YsfijrjVkuS0kJmfrGJ/MkV8Wsx2XL6mRyCBXOUog0U/Nh20ANU8kcmEMkGVtxDUM8hr9QQ5qex/LmSiy8YG4c4mfD6s7KvWnRxJcviXmgUMCgYEA75AQssujQtycWx6fZ/aBQLc6+xSlGaY73k2R8XLwMSASAeq1erxCSsuPF5lPRnQ4VZyfSOV9AcOyLgeJCi4ePJEnfZZMcGkKNt2yMsZoWUlJSmHIXhEEfKqu8Qo0TRu4/vQYPKwTVXdpbZJIlDgzztPdC1gOpCg3QQH16wPyL5sCgYAQ5Ygqj14F+w04Oz7bXMT3i+LyOMqFk3Ztpe8t0RMX7F2A/2spAgMZiOv7U2tmYToJq4TsUDD/aK6rkDR+cmdvsdTwbsdSQfzo8WngKrHsMVW1DpNO0jkiSci8e/EClpF7wigS3np/rw6ekhG4A0fQF5CLvUaC84GZRfVqJTwOewKBgQC4oTKNae54oGgMvewjBtOU2eKmEcIwo3JuoSACkw/U/J+ERKz7W85HsNymVmzHotir+pq0ZtHSI03Wtc4DP4nkKgbifoyI8huCL5igE1PmxFms7vGqtbjcj/tmH/QxHVWVgPCRChmYfACQBvbS7QHYvGYW0RXvpGL5QhaSuybTUwKBgG/p/gsj6yUDAiNhEWpSsMWl/3xJeIobcnH1XQrrXWIzL1xZtX1EkcqLM6++Ojjre3UKj96ZDFRpJH4uxTilE9MDOOf+PLoL01rr1rmzaWDr5NsI3nqz2AS6ZSuofO0rs7nQlKtTnQY0vlzPGqfQp4uQ11KPzO2PB9TEGwnZy5HV", + "Expiration": "24:00:00" + } + } +} +``` + +- **`PrivateKey` goes here even on a validator-only app**, if the app also configures + `Jwt.RootLogin` for isolated local testing (the common case for an internal service — AGENTS.md: + "useful in Development when testing a service in isolation"). Root login self-issues a JWT, + which needs a private key regardless of the app's Staging/Production role. Only omit + `PrivateKey` in Development for an app that genuinely never self-issues locally (e.g. a + pure Public API with no isolated-testing story of its own). +- `Expiration: "24:00:00"` (vs. the base file's `01:00:00`) is the established convention for + Development — longer-lived tokens are less annoying to work with locally. Not required, but + match it unless the user asks otherwise. +- Add a `RootLogin` block (`Username`/`Password`) alongside `Jwt` in Development if this app + should support isolated local testing — ask for credentials, or use a placeholder like + `admin@domain.com` / a throwaway password if the user doesn't care. + +**`appsettings.Staging.json` / `appsettings.Production.json`** — only `Issuer`/`Audience` +overrides, no keys (those come from the Kubernetes secret, never a static file): + +```json +"App": { "Authentication": { "Jwt": { "Issuer": "nano.staging", "Audience": "nano.staging" } } } +``` +```json +"App": { "Authentication": { "Jwt": { "Issuer": "nano.production", "Audience": "nano.production" } } } +``` + +## AuthController (API/Web only) + +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. + +`Controllers/AuthController.cs`, main app project: + +```csharp +public class AuthController(ILogger logger, IAuthRepository authRepository) + : BaseAuthController(logger, authRepository); +``` + +Nothing to implement — every endpoint the current config enables (per AGENTS.md's sub-repository +table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and +`IAuthRepository` to match (same rule as every other controller in this ecosystem). + +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play — either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) — pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above — `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + 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. + +## Kubernetes / GitHub Actions (Staging/Production) — issuer app only + +Only the app that **issues** tokens does this. A validator-only app does **not** create or +re-apply this secret — it only references the `auth-jwt-secret` the issuer already created (see +its `deployment.yaml` entry below). Re-applying it from an app with no real key values set pushes +unexpanded placeholder text into the shared secret, silently corrupting the real one — don't do +it for any app but the issuer. + +1. **Workflow env vars**: + ```yaml + AUTH_JWT_PUBLIC_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PUBLIC_KEY || secrets.STAGING_AUTH_JWT_PUBLIC_KEY }} + AUTH_JWT_PRIVATE_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PRIVATE_KEY || secrets.STAGING_AUTH_JWT_PRIVATE_KEY }} + ``` +2. **`.kubernetes/auth-jwt-secret.yaml`** (new file, issuer app only): + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: auth-jwt-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + jwt-public-key: %AUTH_JWT_PUBLIC_KEY% + jwt-private-key: %AUTH_JWT_PRIVATE_KEY% + ``` + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) — new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +## Kubernetes — deployment.yaml + +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` — **the two app types get +different entries here, not the same block with one line dropped**: + +Issuer app (both keys): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +- name: App__Authentication__Jwt__PrivateKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-private-key +``` + +Validator-only app (`PublicKey` only — no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` + +## API-key authentication + +Not this skill's job — see `nano-add-authentication-apikey`, which works whether or not `Jwt` is +configured on this app. If the user asked for both in one request, run both skills; step 6 above +covers the one thing each needs to know about the other. + +## Generating real keys (Staging/Production, or a deliberate Development change) + +Never hardcode Staging/Production keys — generate a unique pair and store both halves as +GitHub secrets (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), consumed only via the +Kubernetes secret above. Generate with a throwaway Console app (from AGENTS.md / +`Nano.App.Api/README.md`'s `## Authentication` section): + +```csharp +using System.Security.Cryptography; + +using var rsa = RSA.Create(); + +var publicKey = rsa + .ExportRSAPublicKeyPem() + .Replace("-----BEGIN RSA PUBLIC KEY-----", "") + .Replace("-----END RSA PUBLIC KEY-----", "") + .Replace("\n", string.Empty); + +var privateKey = rsa + .ExportRSAPrivateKeyPem() + .Replace("-----BEGIN RSA PRIVATE KEY-----", "") + .Replace("-----END RSA PRIVATE KEY-----", "") + .Replace("\n", string.Empty); + +Console.WriteLine("PUBLIC KEY:"); +Console.WriteLine(publicKey); +Console.WriteLine(); +Console.WriteLine("PRIVATE KEY:"); +Console.WriteLine(privateKey); + +Console.Read(); +``` + +## After making the change + +- Show the user every file touched, grouped by concern (appsettings per environment, the + controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. +- Point them at the snippet above for generating real Staging/Production keys — never the + hardcoded Development pair. +- If they want to change the Development key pair from the shared default, warn explicitly: it + must change **identically across every app** in the solution, or apps stop being able to + validate each other's locally-issued tokens. +- If step 2 stopped the skill early for a missing Identity prerequisite, that's the whole + response — don't partially wire persistent auth while waiting on it. +- If step 6 applied (API-key was already configured), restate the automatic scheme-switch and + the newly-visible `/auth/login/apikey` endpoint one more time — it's a real behavior change on + an app that already had callers, worth a second confirmation, not just a note in passing. 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 new file mode 100644 index 00000000..df9008de --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-availability-check/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-availability-check/SKILL.md new file mode 100644 index 00000000..8030ccb7 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-availability-check/SKILL.md @@ -0,0 +1,142 @@ +--- +name: nano-add-availability-check +description: Add continuous uptime monitoring for a publicly-exposed Nano API or Web application - creates an Azure Application Insights availability (ping) test against the app's /healthz endpoint across every DNS zone, plus a metric alert rule. Requires the app already be publicly exposed. Use when the user asks to add availability monitoring, an uptime check, or a ping test to a Nano application. +--- + +# Nano add availability check + +Adds continuous availability monitoring for an existing, publicly-exposed Nano API or Web +application, via an Azure Application Insights ping test against `/healthz` plus a metric alert. +This is CI/infrastructure only — nothing in the application itself changes. + +## Before making any change, determine + +1. **Is the app publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` + (`nano-add-public-exposure`). **Required** — the ping test hits a real public HTTPS URL; there + is nothing for it to test otherwise. If not present, stop and point the user at + `nano-add-public-exposure` first — don't wire a check against a URL that doesn't resolve. +2. **Is Health Checks enabled?** The test targets `/healthz` specifically, matching on the + `"status":"unhealthy"` string to detect failure — check for `App:HealthCheck` + (`nano-add-health-checks`). If absent, `/healthz` doesn't exist at all; stop and point the + user at that skill too. +3. **Is Availability Check already configured?** Check the workflow for an "Add Availability + Check" step. If present, say so and stop. +4. **`SUB_DOMAIN_NAME` must already be set** (from `nano-add-public-exposure`) — this step reuses + it, doesn't define it. Confirm it's there rather than assuming. + +## GitHub Actions + +1. **Workflow env var**: + ```yaml + AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }} + ``` +2. **"Add Availability Check" step**, placed at the end of the pipeline (after deployment). + Idempotent — creates the web test and alert only if they don't already exist for each DNS + zone, safe to run every deploy: + ```yaml + - name: Add Availability Check + shell: pwsh + run: | + $env:AZURE_LOCATION = az monitor log-analytics workspace list -g $env:AZURE_GROUP_LOGS --query [0].location -o tsv; + $env:APPLICATION_INSIGHT_ID = az monitor app-insights component show -g $env:AZURE_GROUP_LOGS --query [0].id -o tsv; + $env:HIDDEN_LINK = 'hidden-link:' + $env:APPLICATION_INSIGHT_ID + '=Resource'; + + $zoneNames = az network dns zone list -g $env:AZURE_GROUP_DNS --query "[].name" -o json | ConvertFrom-Json + + foreach ($zoneName in $zoneNames) + { + $env:WEB_TEST_NAME = $env:SERVICE_NAME + '-availability-' + $env:ASPNETCORE_ENVIRONMENT.ToLower() + '-' + $env:SUB_DOMAIN_NAME + '-' + ($zoneName.TrimEnd('.') -replace '\.', '-') + + az monitor app-insights web-test show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_NAME --query id -o tsv 2>$null + + if ($LastExitCode -ne 0) + { + az monitor app-insights web-test create ` + -n $env:WEB_TEST_NAME ` + --defined-web-test-name $env:WEB_TEST_NAME ` + -g $env:AZURE_GROUP_LOGS ` + -l $env:AZURE_LOCATION ` + --kind ping ` + --web-test-kind standard ` + --frequency 300 ` + --enabled true ` + --retry-enabled true ` + --ssl-check true ` + --ssl-lifetime-check 30 ` + --http-verb GET ` + --request-url https://$env:SUB_DOMAIN_NAME.$zoneName/healthz ` + --expected-status-code 200 ` + --content-validation content-match='"status":"unhealthy"' ignore-case=true pass-if-text-found=false ` + --tags $env:HIDDEN_LINK ` + --locations Id='us-ca-sjc-azr' ` + --locations Id='us-va-ash-azr' ` + --locations Id='emea-gb-db3-azr' ` + --locations Id='emea-nl-ams-azr' ` + --locations Id='apac-hk-hkn-azr'; + + if ($LastExitCode -ne 0) + { + throw "error"; + } + } + + $env:WEB_TEST_ALERT_NAME = $env:WEB_TEST_NAME + "-alert"; + + az resource show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_ALERT_NAME --query id -o tsv 2>$null; + + if ($LastExitCode -ne 0) + { + $env:WEB_TEST_ID = az monitor app-insights web-test show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_NAME --query id -o tsv; + $env:ACTION_GROUP_ID = az monitor action-group list -g $env:AZURE_GROUP_LOGS --query [0].id -o tsv; + + $alertRuleProperties = @{ + severity = 1 + enabled = $true + scopes = @($env:WEB_TEST_ID, $env:APPLICATION_INSIGHT_ID) + evaluationFrequency = "PT1M" + windowSize = "PT5M" + criteria = @{ + "odata.type" = "Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria" + webTestId = $env:WEB_TEST_ID + componentId = $env:APPLICATION_INSIGHT_ID + failedLocationCount = 2 + } + actions = @( + @{ actionGroupId = $env:ACTION_GROUP_ID } + ) + } + + $json = $alertRuleProperties | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText("$PWD/alert.json", $json, [System.Text.UTF8Encoding]::new($false)) + + az resource create ` + -g $env:AZURE_GROUP_LOGS ` + -n $env:WEB_TEST_ALERT_NAME ` + -l global ` + --resource-type "Microsoft.Insights/metricAlerts" ` + -p '@alert.json'; + + if ($LastExitCode -ne 0) + { + throw "error"; + } + } + } + ``` + +This creates one ping test **per DNS zone** the app is reachable under (matching +`nano-add-public-exposure`'s multi-zone hostname derivation), each pinged from 5 global Azure +locations every 5 minutes, alerting when at least 2 locations report failure within a 5-minute +window. `az monitor log-analytics workspace list`/`az monitor app-insights component show`/ +`az monitor action-group list` all assume a Log Analytics workspace, Application Insights +component, and action group already exist in `AZURE_GROUP_LOGS` — one-time, cluster/subscription- +level prerequisites outside this skill's scope; tell the user if any of those would come back +empty rather than assuming they're provisioned. + +## After making the change + +- Show the user the workflow changes. +- If step 1 or step 2 stopped the skill early, that's the whole response — don't wire a check + against a URL or endpoint that doesn't exist yet. +- Mention that the actual web test/alert resources are created on the **next deploy run**, not + by editing the workflow file alone. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-azure-managed-identity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-azure-managed-identity/SKILL.md new file mode 100644 index 00000000..915f4b03 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-azure-managed-identity/SKILL.md @@ -0,0 +1,113 @@ +--- +name: nano-add-azure-managed-identity +description: Wire Azure Managed Identity federated to Kubernetes Workload Identity onto a Nano.Library-based application's Kubernetes deployment - adds service-account.yaml, the workload-identity pod annotations, and the CI "Managed Identity" step that provisions and federates the identity. Use when the user asks to add Managed Identity, Workload Identity, or passwordless Azure resource access to a Nano API, Web, or Console application - typically a prerequisite before setting a Data or Storage provider to AuthenticationType: Azure. +--- + +# Nano add managed identity + +Wires Azure Managed Identity, federated to Kubernetes Workload Identity, onto an existing Nano +API, Web, or Console application's Kubernetes deployment. This is infrastructure-only — there's +no `App:`/`Data:` config section it owns itself; providers consume the identity it establishes +through their own `AuthenticationType: Azure` setting. `nano-add-data-provider` (MySql/ +PostgreSQL/SqlServer) and `nano-add-storage-provider` (Azure section) both already assume this is +wired before their Staging/Production sections apply — this skill is what makes that true. + +## Before making any change, determine + +1. **Is Managed Identity already wired?** Check for `.kubernetes/service-account.yaml` and a + `Managed Identity` step in the `Kubernetes Deploy` workflow. If present, say so and stop. +2. **What's it for?** Ask if not already given — usually about to back a Data provider + (`AuthenticationType: Azure`) or Azure Storage. This skill only establishes the identity + itself; flipping a provider's `AuthenticationType` is that provider's own skill's job, not + this one's — don't do it here even if the reason is already known. +3. **Application type.** No difference in wiring between API, Web, or Console — whichever of + `deployment.yaml`/`cronjob.yaml` the app has gets the same annotations. +4. **Does the workflow already have `AZURE_GROUP_KUBERNETES`?** Every app's workflow needs it + already for basic AKS deploy (`az aks get-credentials`), so it's virtually always already + present — confirm rather than assume, but there's normally nothing to add for it here. + +## Kubernetes + +1. **`.kubernetes/service-account.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: %SERVICE_NAME%-service-account + namespace: %KUBERNETES_NAMESPACE% + annotations: + azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% + ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) — new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. +2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod + template's metadata and reference the service account in the pod spec: + ```yaml + template: + metadata: + labels: + azure.workload.identity/use: "true" + spec: + serviceAccountName: %SERVICE_NAME%-service-account + ``` + +## GitHub Actions + +Add the `Managed Identity` step, placed after the AKS-credentials step (`az aks get-credentials`) +and before `Kubernetes Deploy`. It's idempotent — creates the identity only if it doesn't already +exist, and (re)creates the federated credential every run regardless: + +```yaml +- name: Managed Identity + shell: pwsh + run: | + $env:IDENTITY_NAME = $env:SERVICE_NAME + "-identity"; + $env:IDENTITY_PRINCIPAL_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query principalId -o tsv; + $env:KUBERNETES_ISSUER_URL = az aks list -g $env:AZURE_GROUP_KUBERNETES --query [0].['oidcIssuerProfile.issuerUrl'] -o tsv; + + if (-not $env:IDENTITY_PRINCIPAL_ID) + { + az identity create ` + -g $env:AZURE_GROUP_KUBERNETES ` + -n $env:IDENTITY_NAME; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + $env:IDENTITY_PRINCIPAL_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query principalId -o tsv; + } + + $env:IDENTITY_CLIENT_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query clientId -o tsv; + + az identity federated-credential create ` + --name $env:SERVICE_NAME-credentials ` + --resource-group $env:AZURE_GROUP_KUBERNETES ` + --identity-name $env:IDENTITY_NAME ` + --issuer $env:KUBERNETES_ISSUER_URL ` + --subject "system:serviceaccount:${env:KUBERNETES_NAMESPACE}:${env:SERVICE_NAME}-service-account" ` + --audience api://AzureADTokenExchange; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + echo "IDENTITY_NAME=$env:IDENTITY_NAME" >> $env:GITHUB_ENV; + echo "IDENTITY_CLIENT_ID=$env:IDENTITY_CLIENT_ID" >> $env:GITHUB_ENV; + echo "IDENTITY_PRINCIPAL_ID=$env:IDENTITY_PRINCIPAL_ID" >> $env:GITHUB_ENV; +``` + +Apply `service-account.yaml` in the `Kubernetes Deploy` step, before `deployment.yaml`/ +`cronjob.yaml` — same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every +other manifest. + +## After making the change + +- Show the user every file touched. +- Remind them this only establishes the identity — nothing consumes it yet. Point them at + `nano-add-data-provider` (set `AuthenticationType: Azure`) or `nano-add-storage-provider` (its + Azure section) as the actual next step for whatever prompted this. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-console-worker/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-console-worker/SKILL.md new file mode 100644 index 00000000..450023ce --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-console-worker/SKILL.md @@ -0,0 +1,60 @@ +--- +name: nano-add-console-worker +description: Add a Console Worker to a Nano Console application - a class deriving BaseWorker that runs a Console app's actual run-to-completion job. Use when the user asks to add a worker, background job, or the main task to a Nano Console application. +--- + +# Nano add console worker + +Adds a Console Worker to an existing Nano Console application. Read AGENTS.md's +`### Console Workers` section first — it documents the lifecycle and error-handling semantics in +full; this skill is just the file shape. + +## Before making any change, determine + +1. **Application type.** Console Workers are a `NanoConsoleApplication`-specific concept — check + `Program.cs`. If the app is API/Web, this isn't the right skill (background work there is a + [Startup Task](nano-add-startup-task) instead, which has different lifecycle semantics — + confirm which the user actually wants). +2. **Name and job.** Ask if not already given — what the worker actually does. +3. **Does it need to signal failure?** Per AGENTS.md's ⚠: unlike a startup task, a worker that + throws does **not** abort anything — the exception is caught and logged, the worker is treated + as complete, and every sibling worker still runs. If this worker's failure should actually + surface (e.g. a non-zero exit code for a CronJob to alert on), that has to be handled inside + `OnStartAsync` itself — ask whether that matters for this job before treating a plain override + as sufficient. + +## Worker class + +`Workers/{Name}Worker.cs` in the application project (conventional location, not enforced — +discovered by type): + +```csharp +public class MyWorker(ILogger logger) : BaseWorker(logger) +{ + public override async Task OnStartAsync(CancellationToken cancellationToken = default) + { + // the actual work + } + + // optional — only override if cleanup is needed; runs after every worker's OnStartAsync + // finishes (concurrently with sibling workers' OnStopAsync), right before the app exits + public override Task OnStopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} +``` + +No registration needed — every non-abstract `IWorker` in the entry assembly is discovered and +registered `Scoped` automatically, same mechanism as Startup Tasks. Any other registered service +can be injected into the constructor alongside `logger`. + +Remember the lifecycle this fits into: all Startup Tasks finish first, then every worker's +`OnStartAsync` runs concurrently with its siblings, then every `OnStopAsync` runs concurrently, +then the process exits on its own (`IHostApplicationLifetime.StopApplication()`) — this is what +makes a Console app a run-to-completion job rather than a long-running daemon. + +## After making the change + +- Show the user the file added. +- If step 3 identified a real failure-signaling need, make sure `OnStartAsync` actually handles + it (e.g. `Environment.ExitCode = 1` before returning, or rethrowing after logging if that's the + intended signal) — don't leave a silently-swallowed failure in a job the user said needs to + alert on error. 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 new file mode 100644 index 00000000..531bfe11 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -0,0 +1,570 @@ +--- +name: nano-add-custom-endpoint +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract — one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 — Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient — not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes — neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action — see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant — don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect — say so rather than tagging it silently. + +## Step 2 — Public API or internal service? + +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** — the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 — Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given — don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way — read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself — and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere — this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method — including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service — a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action — don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project — **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request — don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call — scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change — it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) — a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name — this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +— name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT — don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason — use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) — add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would — flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all — same method, same route, extended behavior — so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default — most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) — override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants — decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method — there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) — it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use — "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) — this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and — for the internal-service path — the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic — the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point — don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response — explain + what already does the job instead of generating anything. 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 new file mode 100644 index 00000000..6bb5bd37 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -0,0 +1,523 @@ +--- +name: nano-add-data-provider +description: Add a Nano data provider (MySql, PostgreSQL, SqlServer, SqLite, or InMemory) to a Nano.Library-based application - registers it in Program.cs, adds the DbContext/DbContextFactory, the Data configuration section, the local docker-compose database service, and (for MySql/PostgreSQL/SqlServer) the Staging/Production migration CI step and Kubernetes secret. Use when the user asks to add a database, persistence, or a specific data provider to a Nano API, Web, or Console application. +--- + +# Nano add data provider + +Wires a Nano data provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first — its `## Nano.Data` section documents the +`Configuration` table, the provider/package table, the exact `DbContext`/`BaseDbContextFactory` +shapes, and `Migrations`/`StartupAction` semantics in full; this skill does not repeat any of +that, only how to apply it and wire the surrounding infrastructure (docker-compose, CI, K8s) +without breaking what's already there. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see + AGENTS.md's provider table for package/type names). Ask the user if not already given. +3. **Is a data provider already registered?** Check `Program.cs` for an existing + `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically + wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm + with the user whether they want to *replace* it (single-context swap) or genuinely add a + second `DbContext` before proceeding either way. +4. **Is a package reference even needed?** Same check as the logging skill: look for a + `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application + project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package + step. Otherwise add `` to + the **application project** (never `.Models`), matching the version of the project's existing + Nano application-type package. Never add a `ProjectReference` to Nano.Library source. +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. + +## Program.cs + +Add the registration inside the existing `.ConfigureServices(...)` lambda (same placeholder/`_` +→ `x` rename rule as the logging skill if the lambda is still the blank-app boilerplate): + +```csharp +using Nano.Data.Extensions; +using Nano.Data.; +using .Data; +``` + +```csharp +x.AddNanoData<Provider, DbContext>(); +``` + +## Data Context and design-time factory + +Create both files in `Data/` in the application project (per AGENTS.md's `### Data Context` +and `#### Design-time factory (migrations)` — copy those shapes exactly, they're not +provider-specific except for the generic arguments): + +- `Data/DbContext.cs` — thin subclass of `BaseDbContext`/`BaseDbContext` with + the exact `(DbContextOptions, IOptionsMonitor)` constructor AGENTS.md shows. Skip + this file for `InMemory` only if the project has no other provider-specific needs — check + AGENTS.md's provider table notes first (`InMemory` still needs a `DbContext`, just no + `BaseDbContextFactory` or migrations). +- `Data/DbContextFactory.cs` — subclass of `BaseDbContextFactory`. + Skip for `InMemory` (no migrations to design-time-construct against). + +## appsettings.json + +Add the `Data` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`). A few placements are load-bearing, not arbitrary: + +- **`ConnectionString`**: leave `null` in the base file; set the real value only in + `appsettings.Development.json` (see below) — never commit a real connection string to the + base file. +- **`AuthenticationType`**: always `"Credentials"` in the base `appsettings.json`, even for + providers that will use Managed Identity in Staging/Production — `Credentials` is the correct + *local* value, and it's what the base file should show either way. Don't set `"Azure"` here; + live environments override it via the Kubernetes ConfigMap (see the Staging/Production + section below), never via a static appsettings file. +- **`StartupAction`**: `"None"` in the base file. Only `appsettings.Development.json` sets it to + `"Migrate"` (AGENTS.md: only enable `Create`/`Migrate` in `Development`). + +In `appsettings.Development.json`, add: + +```json +"Data": { + "StartupAction": "Migrate", + "ConnectionString": "" +} +``` + +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. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. + +## Initial migration + +Skip for `InMemory`. Otherwise, after the `DbContext`/factory files exist: + +```powershell +dotnet ef migrations add Initial --project {project} +``` + +Only run this if the user asked you to, or the project's existing workflow clearly expects a +migration per provider setup — check for a `Migrations/` folder precedent first (per the +entity-scaffold skill's same rule). + +## docker-compose.yml (local Development) + +Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to +the app's own service if not already present. Add only the one block matching the chosen +provider — not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. + +```yaml +# MySql +database: + image: mysql/mysql-server:latest + ports: + - 3306:3306 + networks: + - network + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: myPassword_123 + +# PostgreSQL +database: + image: postgis/postgis:latest + ports: + - 5432:5432 + networks: + - network + environment: + POSTGRES_USER: sa + POSTGRES_PASSWORD: myPassword_123 + POSTGRES_DB: nanoDb + +# SqlServer +database: + image: mcr.microsoft.com/mssql/server:2022-latest + ports: + - 1433:1433 + networks: + - network + environment: + SA_PASSWORD: myPassword_123 + ACCEPT_EULA: Y + MSSQL_PID: Developer +``` + +`SqLite`/`InMemory` need no `database` service — SqLite persists to a local/mounted file, not a +server container. + +## SqLite (Kubernetes persistent volume, not a migration CI step) + +`SqLite` needs no migration CI step and no Managed Identity — it's a local file, not a network +database — but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 + 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): + ```yaml + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: %SERVICE_NAME%-data-storage-class + provisioner: disk.csi.azure.com + parameters: + storageaccounttype: Standard_LRS + kind: Managed + reclaimPolicy: Retain + volumeBindingMode: WaitForFirstConsumer + ``` +- **`ReadWriteOnce`, one disk per pod, not one shared disk.** This disk can only attach to a + single pod — so if the app runs more than one replica, `deployment.yaml`'s `kind: Deployment` + is wrong (every replica shares one pod template and would race to attach the same static PVC; + only the first pod to schedule ever becomes ready). Use `stateful-set.yaml` + (`kind: StatefulSet`) with `volumeClaimTemplates` instead — giving each replica its own + separate disk, and its own separate SqLite database file (not shared across replicas; if the + app needs one *shared* database, that's what the network providers above are for). +- **`.kubernetes/stateful-set.yaml`**: add `serviceName: %SERVICE_NAME%-stateful-headless` alongside + `replicas`/`selector`, mount the volume in the container: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/data + ``` + and, as a top-level sibling of `template:` (not nested inside `template.spec`): + ```yaml + volumeClaimTemplates: + - metadata: + name: %SERVICE_NAME%-volume + spec: + accessModes: + - ReadWriteOnce + storageClassName: %SERVICE_NAME%-data-storage-class + resources: + requests: + storage: %SQL_SIZE%Gi + ``` + Needs `SQL_SIZE: 10` (a bare number of GB, e.g. `10` — the template above appends `Gi`; or + whatever size the user wants) added to the workflow env block. +- **`.kubernetes/service-headless.yaml`** (new file) — required by the `StatefulSet`'s + `serviceName` field, separate from the app's normal `ClusterIP` service: + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: %SERVICE_NAME%-stateful-headless + namespace: %KUBERNETES_NAMESPACE% + spec: + clusterIP: None + ports: + - name: http + port: 8080 + selector: + app: %SERVICE_NAME% + ``` +- **`.kubernetes/autoscaler.yaml`** — always present on an API/Web app (per AGENTS.md's Solution + Structure), the only app types this `StatefulSet` conversion ever applies to: change + `scaleTargetRef.kind` from `Deployment` to `StatefulSet`. +- **Kubernetes Deploy workflow step**: apply `data-storageclass.yaml` (still needed — referenced + by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | + ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before + `stateful-set.yaml`. There's no separate PVC file to apply — `volumeClaimTemplates` creates one + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) — new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. +- No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` + change — none of the Staging/Production section below applies to SqLite. + +## InMemory (nothing further) + +No `DbContext`/factory beyond the plain `DbContext` itself, no migrations, no `docker-compose` +service, no Kubernetes changes, no Staging/Production section. `Program.cs` registration and the +base `Data` config (with `ConnectionString` left `null`) are the entire job. + +## Staging/Production (MySql, PostgreSQL, SqlServer only — SqLite/InMemory covered above) + +This section assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` +— a separate, prerequisite skill: service-account.yaml, workload-identity annotations, the CI +"Managed Identity" step that produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/ +`$env:IDENTITY_PRINCIPAL_ID`). If the project doesn't have that yet, point the user at that skill +first rather than wiring a migration step that references identity variables that don't exist. + +It also assumes the target Azure database **server** resource already exists (a MySQL/PostgreSQL +Flexible Server, or an Azure SQL **server** — not the same as the individual database on it). +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: + AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} + DOTNET_EF_TOOLS_VERSION: "10.0" + ``` + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** — add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure + server, runs `dotnet ef database update` using an elevated/admin credential, then grants the + app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds + the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: + + ```yaml + - name: MySQL Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].databasePort -o tsv; + $env:SQL_SERVER = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + $env:SQL_USER = az mysql flexible-server ad-admin list -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER --query "[0].login" -o tsv; + $env:SQL_TOKEN = az account get-access-token --resource-type oss-rdbms --query accessToken -o tsv; + + $env:DATA__CONNECTIONSTRING = "Server=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Uid=$env:SQL_USER;Pwd=$env:SQL_TOKEN;SslMode=Required"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:APP_USER_SQL_PATH = "app-database-user.sql"; + $sql = @" + CREATE AADUSER IF NOT EXISTS '$env:IDENTITY_NAME' IDENTIFIED BY '$env:IDENTITY_CLIENT_ID'; + GRANT SELECT, INSERT, UPDATE, DELETE ON $env:SQL_NAME.* TO '$env:IDENTITY_NAME'@'%'; + FLUSH PRIVILEGES; + "@; + $sql | Set-Content $env:APP_USER_SQL_PATH; + + az mysql flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN --file-path $env:APP_USER_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:SQL_CONNECTIONSTRING = "Server=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Uid=$env:IDENTITY_NAME;SslMode=Required"; + echo "SQL_CONNECTIONSTRING=$env:SQL_CONNECTIONSTRING" >> $env:GITHUB_ENV; + ``` + + ```yaml + - name: PostgreSQL Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = 5432; + $env:SQL_SERVER = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + $env:SQL_USER = az postgres flexible-server microsoft-entra-admin list -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER --query "[0].principalName" -o tsv; + $env:SQL_TOKEN = az account get-access-token --resource-type oss-rdbms --query accessToken -o tsv; + + $env:DATA__CONNECTIONSTRING = "Host=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Username=$env:SQL_USER;Password=$env:SQL_TOKEN;SSL Mode=Require;Trust Server Certificate=true"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:PRINCIPAL_SQL_PATH = "app-database-principal.sql"; + $env:GRANTS_SQL_PATH = "app-database-grants.sql"; + $principalSql = @" + DO `$`$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$env:IDENTITY_NAME') THEN + PERFORM pgaadauth_create_principal('$env:IDENTITY_NAME', false, false); + END IF; + END + `$`$; + "@; + $principalSql | Set-Content $env:PRINCIPAL_SQL_PATH; + az postgres flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN -d postgres --file-path $env:PRINCIPAL_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $grantsSql = @" + GRANT CONNECT ON DATABASE "$env:SQL_NAME" TO "$env:IDENTITY_NAME"; + GRANT USAGE ON SCHEMA public TO "$env:IDENTITY_NAME"; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "$env:IDENTITY_NAME"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "$env:IDENTITY_NAME"; + "@; + $grantsSql | Set-Content $env:GRANTS_SQL_PATH; + az postgres flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN -d $env:SQL_NAME --file-path $env:GRANTS_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:SQL_CONNECTIONSTRING = "Host=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Username=$env:IDENTITY_NAME;SSL Mode=Require;Trust Server Certificate=true"; + echo "SQL_CONNECTIONSTRING=$env:SQL_CONNECTIONSTRING" >> $env:GITHUB_ENV; + ``` + + `SqlServer` needs **two** steps, not one — unlike MySQL/PostgreSQL Flexible Server (one + server hosts many databases, and EF's `database update` can create the database itself), + Azure SQL treats each database as its own billable resource that must be explicitly created + first: + + ```yaml + - name: SQL Server Create Database + shell: pwsh + run: | + $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; + $env:SQL_EDITION = "GeneralPurpose"; + $env:SQL_MAX_SIZE = "64GB"; + $env:SQL_BACKUP_RETENTION = "35" + $env:SQL_SERVER_NAME = az sql server list -g $env:AZURE_GROUP_DATABASE --query "[0].name" -o tsv; + $env:SQL_DB_EXISTS = az sql db show -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --query name -o tsv 2>$null; + + if (-not $env:SQL_DB_EXISTS) + { + az sql db create ` + -n $env:SQL_NAME ` + -s $env:SQL_SERVER_NAME ` + -g $env:AZURE_GROUP_DATABASE ` + --edition $env:SQL_EDITION ` + --service-objective $env:SQL_SERVICE_OBJECTIVE ` + --max-size $env:SQL_MAX_SIZE ` + --backup-storage-redundancy Geo ` + --zone-redundant true; + + $env:MAINTENANCE_CONFIG_ID = "/subscriptions/$env:AZURE_SUBSCRIPTION_ID/providers/Microsoft.Maintenance/publicMaintenanceConfigurations/SQL_Default"; + + az sql db update ` + -n $env:SQL_NAME ` + -s $env:SQL_SERVER_NAME ` + -g $env:AZURE_GROUP_DATABASE ` + --maint-config-id $env:MAINTENANCE_CONFIG_ID; + + $env:DIAGNOSTIC_SETTINGS_NAME = "diagnostics-" + $env:SQL_NAME; + $env:SQL_LOGS_PATH = "sql-diagnostic-logs.json"; + $env:SQL_METRICS_PATH = "sql-diagnostic-metrics.json"; + $env:WORKSPACE_ID = az monitor log-analytics workspace list -g $env:AZURE_GROUP_LOGS --query [0].[id] -o tsv; + $env:SQLDB_ID = az sql db show -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --query id -o tsv; + + $logsJson = @" + [ + { "category": "QueryStoreRuntimeStatistics", "enabled": true }, + { "category": "SQLSecurityAuditEvents", "enabled": true } + ] + "@; + $logsJson | Set-Content $env:SQL_LOGS_PATH; + + $metricsJson = @" + [ + { "category": "Basic", "enabled": true }, + { "category": "InstanceAndAppAdvanced", "enabled": true }, + { "category": "WorkloadManagement", "enabled": true } + ] + "@; + $metricsJson | Set-Content $env:SQL_METRICS_PATH; + + az monitor diagnostic-settings create ` + --name $env:DIAGNOSTIC_SETTINGS_NAME ` + --resource $env:SQLDB_ID ` + --workspace $env:WORKSPACE_ID ` + --logs "@$env:SQL_LOGS_PATH" ` + --metrics "@$env:SQL_METRICS_PATH"; + + $env:ACTION_GROUP = az monitor action-group list -g $env:AZURE_GROUP_LOGS --query [0].[id] -o tsv; + + az monitor metrics alert create --name "High CPU Usage" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg cpu_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when CPU usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Memory And Worker Usage" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg workers_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when worker/session usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Number Of Connections" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "total connection_successful > 100" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when the number of successful connections exceeds 100 in 5 minutes."; + az monitor metrics alert create --name "High Storage IO" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg physical_data_read_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when data IO usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Storage Percent" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg storage_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when Storage usage exceeds 80% for 5 minutes."; + + az sql db str-policy set -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --retention-days $env:SQL_BACKUP_RETENTION; + + if ($LastExitCode -ne 0) { throw "error"; }; + }; + ``` + + This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) — safe to always include, it only + acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added + to the workflow env block alongside `AZURE_GROUP_DATABASE` — but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. + + ```yaml + - name: SQL Server Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = 1433; + $env:SQL_SERVER = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + + $env:DATA__CONNECTIONSTRING = "Server=$env:SQL_HOST,$env:SQL_PORT;Database=$env:SQL_NAME;Authentication=Active Directory Service Principal;User Id=$env:AZURE_CLIENT_ID;Password=$env:AZURE_CLIENT_SECRET;Encrypt=True;TrustServerCertificate=True;"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + ``` + + ⚠ Unlike MySQL/PostgreSQL, this reference implementation doesn't build a passwordless + `SQL_CONNECTIONSTRING`/grant step for SQL Server afterward — it runs the migration with the + service principal's own credentials and stops there. If the user wants runtime + Managed-Identity auth for SQL Server specifically rather than the service-principal + credentials shown, flag that as a gap to resolve with them rather than inventing the missing + grant step. + +3. **Kubernetes secret** — add `.kubernetes/auth-sql-secret.yaml`: + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: %SERVICE_NAME%-sql-auth-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + data-connectionstring: %SQL_CONNECTIONSTRING% + ``` + Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | + Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) — new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. +4. **ConfigMap** — add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. + This is what actually makes the live environment use `Azure` auth — the base + `appsettings.json` stays `Credentials` always (see above); this env var overrides it at + runtime. +5. **Deployment** — add to `.kubernetes/deployment.yaml`'s container `env`: + ```yaml + - name: Data__ConnectionString + valueFrom: + secretKeyRef: + name: %SERVICE_NAME%-sql-auth-secret + key: data-connectionstring + ``` + +## After making the change + +- Show the user every file touched, grouped by concern (app code, local docker-compose, + Staging/Production CI + K8s) — this skill touches more files than most, so a flat list is + harder to sanity-check than a grouped one. +- If package/DbContext/migration steps were skipped (`InMemory`, or `NanoCore`/`Nano.All` + already covering the package), say so explicitly. +- If the Staging/Production section was skipped because Managed Identity isn't set up yet (point + the user at `nano-add-azure-managed-identity`), or because the SQL Server target database doesn't + exist, say so explicitly rather than silently doing only the local-dev half of the job. 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 new file mode 100644 index 00000000..994e8e3f --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -0,0 +1,305 @@ +--- +name: nano-add-entity +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context — with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request — need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app — otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently — it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** — that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** — move on to the next entity. + + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose — don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` — its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 — Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` — ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + — use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 — Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout — mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration — omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly — the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default — explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship — `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** — declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be — don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed — Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity — check `Migrations/` for + precedent first). + +## File 3 — Query criteria (API/Web only — skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by — don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 — Controller (API/Web only — skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument — an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed — Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-event-handler/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-eventing-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-eventing-provider/SKILL.md new file mode 100644 index 00000000..9e154f8e --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -0,0 +1,156 @@ +--- +name: nano-add-eventing-provider +description: Add a Nano eventing provider (currently only RabbitMq) to a Nano.Library-based application - registers it in Program.cs, adds the Eventing configuration section, the local docker-compose broker service, and the Kubernetes secret reference for Staging/Production. Use when the user asks to add eventing, pub/sub messaging, or a message broker to a Nano API, Web, or Console application. +--- + +# Nano add eventing provider + +Wires a Nano eventing provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first — its `## Nano.Eventing` section documents the +`Configuration` table, the provider/package table, and `Publish and Subscribe`/`BaseEventHandler` +usage in full; this skill does not repeat any of that, only how to apply it and wire the +surrounding infrastructure (docker-compose, K8s) without breaking what's already there. + +Considerably simpler than the data-provider skill: there's no CI migration step, no Managed +Identity pairing, and no per-app secret to create — RabbitMQ credentials come from a +**pre-existing, shared, cluster-wide** Kubernetes secret, not something this skill provisions. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user + names something else, check whether a custom provider already exists in the project first, + per AGENTS.md's `#### Custom eventing provider` section). +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing + `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here + (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` + implementation). If one is already registered, treat this as a replace and say so, the same + as the logging skill. +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for + `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the + package step. Otherwise add `` + to the **application project**, matching the version of the project's existing Nano + application-type package. Never a `ProjectReference` to Nano.Library source. + +## Program.cs + +```csharp +using Nano.Eventing.Extensions; +using Nano.Eventing.RabbitMq; +``` + +```csharp +x.AddNanoEventing(); +``` + +Same `.ConfigureServices(...)` lambda placement and `_` → `x` placeholder-rename rule as the +other add-provider skills. + +## appsettings.json + +Add the `Eventing` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`), with one placement split that example doesn't spell out — +unlike the Data provider's `ConnectionString`, most of this section is **not** sensitive: + +- `Host` stays filled in (`"rabbitmq"`, the docker-compose service name below) at the **base** + level — it's not sensitive, and it's already the correct value for local `Development`, so no + `appsettings.Development.json` override is needed for it. Staging/Production override `Host` + (and everything else) via the Kubernetes secret below, not a static appsettings file. +- Only `Credentials.Id`/`Credentials.Secret` are secret — leave them `null` in the base file, and + set the real local values (matching the docker-compose broker's own credentials below) in + `appsettings.Development.json`. +- Include `HealthCheck` only if the project's `App.HealthCheck` is actually enabled — adding a + dependency-level health check when nothing reads the app-level `/healthz` endpoint is dead + configuration that Kubernetes probes would point at without effect. If unsure, check + `Program.cs`'s `.ConfigureApp()` chain / the base `appsettings.json` for `App.HealthCheck` first. + +## docker-compose.yml (local Development) + +Add an `eventing` service to `.docker/docker-compose.yml`, and add it to the app's own service's +`depends_on` if not already present: + +```yaml +eventing: + image: rabbitmq:management + hostname: rabbitmq + ports: + - 5671:5671 + - 5672:5672 + - 15671:15671 + - 15672:15672 + networks: + - network + environment: + RABBITMQ_DEFAULT_USER: rabbitmq_user + RABBITMQ_DEFAULT_PASS: password + RABBITMQ_DEFAULT_VHOST: / +``` + +`hostname: rabbitmq` is why the base `appsettings.json`'s `Eventing:Host` can just be +`"rabbitmq"` without a Development-specific override — it resolves directly on the compose +network. + +## Existing entity controllers + +Per AGENTS.md's `#### Entity controller hierarchy`, every entity controller's constructor +already has a place for `IEventing? eventing = null` — it's optional, so a controller written +before eventing existed simply omits it. Now that a provider is registered, retrofit every +existing entity controller (the full `BaseEntity*Controller`/`BaseEntityUserController` hierarchy +— check each for a constructor that's missing the parameter) to add it, so they can publish +events without a second pass later: + +```csharp +public class MyEntitysController(ILogger logger, IRepository repository, IEventing? eventing) + : BaseEntityController(logger, repository, eventing); +``` + +For a `BaseEntityUserController` (see `nano-add-identity`), `eventing` goes between `repository` +and `identityRepository`, in that order. This is purely additive and safe — the parameter is +nullable, so it doesn't change behavior for a controller that never ends up using it. + +## Staging/Production (Kubernetes) + +No CI step and no per-app secret to create — RabbitMQ is a **pre-existing, shared, cluster-wide** +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: + +Add to `.kubernetes/deployment.yaml`'s container `env`: + +```yaml +- name: Eventing__Host + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: host +- name: Eventing__Port + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: port +- name: Eventing__Credentials__Id + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: username +- name: Eventing__Credentials__Secret + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: password +``` + +## After making the change + +- Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + + Development), the docker-compose `eventing` service, the `deployment.yaml` env entries, and + every entity controller retrofitted with `IEventing? eventing`. +- If a package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. +- Mention `AGENTS.md`'s `Publish and Subscribe` section as the next read if the user also wants + to actually publish/handle events, not just have the broker wired — this skill only wires the + provider, it doesn't scaffold event contracts or handlers. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-health-checks/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-health-checks/SKILL.md new file mode 100644 index 00000000..702dee27 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-health-checks/SKILL.md @@ -0,0 +1,82 @@ +--- +name: nano-add-health-checks +description: Enable Nano's built-in health checks (App:HealthCheck) on a Nano API or Web application - adds the config plus the Kubernetes liveness/readiness probes a fresh app ships without. Use when the user asks to add health checks, a /healthz endpoint, or liveness/readiness probes to a Nano API or Web application. +--- + +# Nano add health checks + +Enables Nano's built-in `/healthz` endpoint on an existing Nano API or Web application. Read +AGENTS.md's `#### Health Checks` section first — it documents the response shape and the +health-is-a-tree propagation model in full; this skill is config plus the Kubernetes wiring a +fresh app doesn't have yet. + +**API/Web only.** Console apps have no HTTP pipeline at all — there's nothing to expose `/healthz` +on. If the target is a Console app, stop and say so rather than adding dead config. + +**This is not just a config flip.** `UseNanoHealthChecks` no-ops entirely when `App:HealthCheck` +isn't configured — `/healthz` genuinely doesn't exist without it. A minimal Nano app ships with +**no Kubernetes liveness/readiness probes at all** (verified: `nanocore-api-minimal`'s +`deployment.yaml` has none), specifically because probing a path that doesn't exist would fail +forever and crash-loop the pod. So enabling health checks means adding the config **and** the +probes together — never one without the other. + +## Before making any change, determine + +1. **Application type.** Confirm API or Web via `Program.cs`. Stop for Console. +2. **Is `App:HealthCheck` already configured?** Check the base `appsettings.json`. If present, + check whether `.kubernetes/deployment.yaml`/`stateful-set.yaml` already has the matching + probes — if the config exists but the probes don't (or vice versa), that's the broken + half-state described above; fixing it is this skill's job even though nothing needs "adding" + config-wise. +3. **Any provider health checks waiting to activate?** Check for `HealthCheck` blocks already + present under `Data`/`Eventing`/`Storage`/`App:Apis:{Client}` config — those are dead + configuration until `App:HealthCheck` exists (AGENTS.md: "must also be enabled at the + `App:HealthCheck` level"). Not a blocker, just worth mentioning — they'll start working the + moment this change lands. + +## appsettings.json + +Add to the base `appsettings.json`, sibling of `App:Version`/`App:Hosting`: + +```json +"App": { "HealthCheck": { } } +``` + +No options — presence alone enables it. Same in every environment; no Development-specific +override needed. + +## Kubernetes + +Add liveness and readiness probes to `.kubernetes/deployment.yaml`'s (or `stateful-set.yaml`'s) +container spec: + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + periodSeconds: 10 + initialDelaySeconds: 30 + timeoutSeconds: 2 +readinessProbe: + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + periodSeconds: 5 + initialDelaySeconds: 20 + timeoutSeconds: 2 +``` + +These values (period/delay/timeout) match every existing Nano app with health checks enabled — +match them rather than inventing different numbers unless the user asks for something specific. + +## After making the change + +- Show the user every file touched. +- If step 3 found dormant provider health checks, tell the user explicitly which ones just + became active — they'll now appear in the `/healthz` response tree and can affect the overall + reported status. +- If step 2 found a broken half-state (config without probes, or probes without config), say + clearly what was actually wrong before this fix, not just what was added. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md new file mode 100644 index 00000000..478586e4 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md @@ -0,0 +1,166 @@ +--- +name: nano-add-identity +description: Configure Nano's persistent Identity store (Data:Identity) on a Nano.Library-based application that already has a Data provider - adds the Identity configuration section and the User entity/mapping/controller triplet Nano's identity actions attach to. Use when the user asks to add user accounts, a user store, sign-up, or persistent identity to a Nano API, Web, or Console application - not when they ask for login/JWT/authentication itself, that's a separate concern. +--- + +# Nano add identity + +Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the +full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, +only how to apply it and add the `User` entity Nano's identity actions attach to. + +**Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the +persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents +JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and +identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any +way to log in. If the user actually wants login/JWT, that's a different skill. + +## Before making any change, determine + +1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers + § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes + `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an + internal network — never on an app reachable directly from the internet. If the request is + actually "add login/signup to our Public API," that's **not** this skill: point the user at + composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) + instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint + its own tokens with server-computed claims. Only proceed with this skill once it's confirmed + this app is (or is becoming) the internal service that actually owns the `User` entity. + + **Also check whether this app is already publicly exposed** — look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files + `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant + to expose may have been anyway, or an app built as an internal service may have picked up + public exposure later for an unrelated reason. If either file is present, **stop before + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put + its anonymous password-reset endpoints on the open internet the moment this skill finishes, + not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is + intentional before proceeding. +2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or + provider — with none registered, stop and tell the user a Data provider needs to be added + first (see `nano-add-data-provider`). +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) — this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged — this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** — create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so + whichever Data provider package is already referenced already carries them. Nothing to add + here. +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and + `IIdentityRepository` must agree with it. +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full + entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no + HTTP surface to route identity actions through), unless the user explicitly wants to drive + identity from repository code in a worker. + +## appsettings.json + +Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base +`appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, +the whole section lives in the base file — nothing in it is an environment-specific secret, so +there's no `appsettings.Development.json` split to make. + +- Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity + models to be audited. +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only + once API-key authentication is added on top, which is Authentication's job, not this skill's; + adding it here with nothing consuming it yet is dead config. + +## User entity, mapping, and controller + +This is the entity-scaffold skill's file set, with identity-specific base classes in place of +the plain ones — read that skill first for the file-location/project-layout rules (split +`.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity +name if not given (conventionally `User`) and any additional properties beyond what +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. + +- **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself — just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. +- **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from + `BaseEntityUserMapping`/`` (namespace + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally + configures the required 1:1 relationship to the underlying `IdentityUser` row and an + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing + identity-specific here. +- **Controller** (API/Web only, `Controllers/sController.cs`): derive from + `BaseEntityUserController`/`` (namespace + `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional + constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them — this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: + ```csharp + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, eventing, identityRepository); + ``` + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` + table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints + that don't match the current configuration (e.g. API-key management when API-key auth isn't + enabled) aren't registered at all — nothing further to do for those until that's added. + +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't +leave that as a follow-up the user has to remember separately. + +## After making the change + +- Show the user every file touched. +- Remind them explicitly: this adds accounts and identity-management endpoints, but no way to + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints + are reachable by an actual caller. +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire + Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-logging-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-logging-provider/SKILL.md new file mode 100644 index 00000000..a4156eae --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-logging-provider/SKILL.md @@ -0,0 +1,80 @@ +--- +name: nano-add-logging-provider +description: Add a Nano logging provider (Log4Net, Microsoft, NLog, or Serilog) to a Nano.Library-based application - registers the provider in Program.cs and adds the Logging configuration section to appsettings.json. Use when the user asks to add logging, set up a specific logging provider, or switch the logging provider in a Nano API, Web, or Console application. +--- + +# Nano add logging provider + +Wires a Nano logging provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first — it documents `Nano.Logging`'s registration +one-liner, the four providers' package names, and the exact `Logging` config shape/defaults +under its `## Nano.Logging` section; this skill does not repeat any of that, only how to apply +it correctly to an existing project without breaking what's already there. + +## Before making any change, determine + +1. **Which provider.** One of `Log4Net`, `Microsoft`, `NLog`, `Serilog` (see AGENTS.md's + provider table for the package/type names). Ask the user if not already given. +2. **Is a provider already registered?** Nano supports exactly one logging provider at a + time — check `Program.cs` for an existing `.AddNanoLogging<...>()` call. If one exists for + a *different* provider, tell the user this will replace it (remove the old `using`, + provider call, and reference) rather than silently adding a second one. If it's already the + *same* provider, say so and stop — nothing to do. +3. **Is a package reference even needed?** Check whether the provider's type already resolves + without adding anything: look for a `PackageReference` to `NanoCore` or `Nano.All` (they're + identical, see AGENTS.md) on the application project itself, or on a `.Models` project it + reaches via `ProjectReference` (AGENTS.md's "quick start" convention — either package pulls + in every Nano package, including every logging provider, transitively). If found, **no + package change is needed at all** — skip straight to Program.cs. + - Otherwise, the project uses the explicit/granular convention: add + `` to the + **application project's** `.csproj` (never a `.Models` project — per AGENTS.md, providers + belong on the app project, `Nano.App` is the only Nano package `.Models` needs), using the + **exact same version** as the project's existing `Nano.App.Api`/`Nano.App.Web`/ + `Nano.App.Console` reference. Don't invent or guess a version. + - Never add a `ProjectReference` to Nano.Library source — always a NuGet `PackageReference`, + even if the rest of the project currently references Nano.Library from source. Some + internal Nano.Library development repos do that for their own convenience but explicitly + document it as something to replace with NuGet packages before deployment — it's not the + convention to extend into a new reference. + +## Program.cs + +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda — don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one — `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. + +- If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ + => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and + remove the placeholder comment — `x` is the Nano convention once the lambda holds a real + registration. +- If other real service registrations already exist in the lambda, just add the + `AddNanoLogging<...>()` call alongside them; don't touch unrelated lines. +- Works identically for `NanoApiApplication`, `NanoWebApplication`, and `NanoConsoleApplication` + — the `.ConfigureServices(...)` call and `AddNanoLogging<...>()` registration are the same + across all three app types (`NanoWebApplication` extends `NanoApiApplication`). + +## appsettings.json + +Add the `Logging` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` only (sibling of `App`, not nested inside it) — no environment-overlay file +needs it. + +- If a `Logging` section already exists (e.g. from a previously-registered different provider), + leave its `LogLevel`/`LogLevelOverrides` values as-is — they're provider-agnostic — and only + touch `Program.cs` and the package/project reference. + +## After making the change + +- Show the user the modified `Program.cs` lines, the `appsettings.json` addition, and — if one + was needed — the `PackageReference` added to the `.csproj`. If none was needed (NanoCore/ + Nano.All already covers it), say so explicitly rather than leaving it unmentioned. +- If this replaced a different provider, explicitly list what was removed (old `using`, + provider call, and reference if one was added for it) alongside what was added, so the user + can sanity-check the swap. +- Don't add any package beyond the logging provider itself, and don't touch Docker/Kubernetes/CI + files — logging provider selection has no effect on any of those. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-metrics/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-metrics/SKILL.md new file mode 100644 index 00000000..6245332f --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-metrics/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-add-metrics +description: Enable Nano's built-in OpenTelemetry metrics (App:Metrics) on a Nano API or Web application - adds the config and the Kubernetes ServiceMonitor for Prometheus scraping. Use when the user asks to add metrics, Prometheus, OpenTelemetry, or a /metrics endpoint to a Nano API or Web application. +--- + +# Nano add metrics + +Enables Nano's built-in `/metrics` endpoint (Prometheus-compatible, via OpenTelemetry) on an +existing Nano API or Web application. Read AGENTS.md's `#### Metrics (OpenTelemetry)` section +first; this skill is just the wiring. + +**API/Web only** — same reasoning as Health Checks: Console apps have no HTTP pipeline, so +there's no `/metrics` to expose. + +**Independent of Health Checks.** Verified directly against the registration code +(`AddNanoMetrics`/`UseNanoMetrics`) — Metrics has no dependency on `App:HealthCheck` in either +direction. Enable it on its own; don't add Health Checks "because Metrics needs it" — it doesn't. + +## Before making any change, determine + +1. **Application type.** Confirm API or Web via `Program.cs`. Stop for Console. +2. **Is `App:Metrics` already configured?** Check the base `appsettings.json`. If present, check + whether `.kubernetes/service-monitor.yaml` already exists — same "don't leave it half-wired" + concern as Health Checks, though less severe here since nothing actively breaks without the + `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group — the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") — not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag — + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. + +## appsettings.json + +Add to the base `appsettings.json`, sibling of `App:Version`/`App:Hosting`: + +```json +"App": { "Metrics": { } } +``` + +No options — presence alone enables it. Same in every environment. + +## Kubernetes + +`.kubernetes/service-monitor.yaml` (new file): + +```yaml +apiVersion: azmonitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: %SERVICE_NAME%-monitor + namespace: %KUBERNETES_NAMESPACE% +spec: + selector: + matchLabels: + app: %SERVICE_NAME% + endpoints: + - port: http + path: /metrics + interval: 1m +``` + +Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) — new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. + +## After making the change + +- Show the user every file touched. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-public-exposure/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-public-exposure/SKILL.md new file mode 100644 index 00000000..f333eac3 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-public-exposure/SKILL.md @@ -0,0 +1,191 @@ +--- +name: nano-add-public-exposure +description: Expose a Nano API or Web application publicly - adds HTTPS hosting config, Kubernetes HTTPRoute (Gateway API) resources for ports 80/443, and the CI step that derives the app's public hostname from every configured Azure DNS zone. Use when the user asks to expose a Nano application publicly, add HTTPS/a public domain, or add an HTTPRoute to a Nano API or Web application. +--- + +# Nano add public exposure + +Exposes an existing Nano API or Web application publicly — Kubernetes-internal (`ClusterIP`) +services aren't reachable from outside the cluster by default; this wires the Gateway API +routing, TLS, and DNS pieces needed to reach it at a real public hostname. Read AGENTS.md's +`##### Https` section (under `#### Hosting`) first for the config table; this skill is the +surrounding infrastructure. + +**Ask whether Availability Check should be added too.** Once an app is publicly reachable, +continuous uptime monitoring (`nano-add-availability-check`) becomes possible for the first +time — it specifically requires this. Ask the user up front rather than assuming either way. + +## Before making any change, determine + +1. **Application type.** API or Web only — Console apps have no HTTP surface to expose. Confirm + via `Program.cs`. +2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ + `httproute-443.yaml`. If present, say so and stop. +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see + above. If yes, run `nano-add-availability-check` after this skill completes (it depends on + the hostname/HTTPS wiring this skill adds). +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, + `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target + Azure resource group at deploy time (an app can end up reachable under several zones/domains + at once, not just one). + +## appsettings.json + +Base `appsettings.json`: no change — HTTP stays exposed as-is (`App:Hosting:Http`, unaffected). + +`appsettings.Development.json` — HTTPS is a **local-development-only** concern; `Staging`/ +`Production` TLS terminates at the gateway/cert-manager level, not via this config (AGENTS.md's +own note): + +```json +"App": { + "Hosting": { + "Http": { "UseHttpsRedirection": true }, + "Https": { + "Ports": [4443], + "Certificate": { + "Path": "/root/.dotnet/https/localhost.pfx", + "Password": "password" + }, + "UseHttpsRequired": true + } + } +} +``` + +Avoid port `443` here specifically — AGENTS.md notes it can trigger security warnings inside +Kubernetes; `4443` (or similar) is the established convention. A self-signed +`localhost.pfx`/password pair is needed for the certificate path to resolve locally — check +whether the project already has one (`dotnet dev-certs https` can generate one if not). + +## docker-compose.yml (local Development) + +Map the HTTPS port and certificate volume onto the app's own service: + +```yaml +services: + {service-name}: + ports: + - 4443:4443 + volumes: + - ../:/root/.dotnet/https +``` + +## Kubernetes + +Two new files. `service.yaml` itself needs **no change** — it keeps exposing the plain HTTP port; +the Gateway routes HTTPS traffic to it and terminates TLS itself. + +`.kubernetes/httproute-80.yaml` (redirects HTTP → HTTPS): + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: %SERVICE_NAME%-route-80 + namespace: %KUBERNETES_NAMESPACE% +spec: + parentRefs: + - name: %GATEWAY_NAME% + sectionName: http + hostnames: +%ROUTE_HOST_NAMES% + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +``` + +`.kubernetes/httproute-443.yaml` (the real route to the app): + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: %SERVICE_NAME%-route-443 + namespace: %KUBERNETES_NAMESPACE% +spec: + parentRefs: + - name: %GATEWAY_NAME% + hostnames: +%ROUTE_HOST_NAMES% + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: %SERVICE_NAME% + port: 8080 +``` + +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) — new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + +`%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars — they're derived at +deploy time (see below), one hostname line per DNS zone found in the target Azure resource +group, so an app can be reachable under multiple domains without per-domain config. + +## GitHub Actions + +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: + ```yaml + SUB_DOMAIN_NAME: + AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} + ``` +2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is + applied: + ```powershell + $zoneNames = az network dns zone list -g $env:AZURE_GROUP_DNS --query "[].name" -o json | ConvertFrom-Json + + $env:ROUTE_HOST_NAMES = ( + $zoneNames | ForEach-Object { + " - $env:SUB_DOMAIN_NAME.$_" + } + ) -join "`n" + + $env:GATEWAY_NAME = kubectl get gateway -n $env:KUBERNETES_NAMESPACE -o jsonpath='{.items[0].metadata.name}' + ``` +3. Apply `httproute-80.yaml`/`httproute-443.yaml` in `Kubernetes Deploy`, same + `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other manifest. + This assumes a `Gateway` resource already exists in the target namespace — provisioning the + Gateway itself is a one-time, cluster-level concern outside this skill's scope; tell the user + if `kubectl get gateway` would come back empty rather than assuming it's there. + +## After making the change + +- Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to + `nano-add-availability-check` next rather than leaving it unaddressed. +- Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a + related but separate concern worth considering for a publicly-reachable app — this skill + doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-startup-task/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-startup-task/SKILL.md new file mode 100644 index 00000000..b0fa8f53 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-startup-task/SKILL.md @@ -0,0 +1,67 @@ +--- +name: nano-add-startup-task +description: Add a Startup Task to a Nano application - a class deriving BaseStartupTask that runs one-time initialization before the app accepts traffic (API/Web) or workers start (Console). Use when the user asks to add cache warm-up, a startup check, or one-time initialization to a Nano API, Web, or Console application. +--- + +# Nano add startup task + +Adds a Startup Task to an existing Nano API, Web, or Console application. Read AGENTS.md's +`### Start-Up Tasks` section first — it documents execution/readiness semantics in full; this +skill is just the file shape. Not the same mechanism as Nano's built-in data-provider migration +task — this is for your own one-time initialization work. + +## Before making any change, determine + +1. **Name and job.** Ask if not already given — what needs to happen once before the app is + considered ready (cache warm-up, an external dependency check, etc.). +2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the + exception propagates and the application fails to start** — confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate — the task itself + still runs at the same point in startup either way, only the failure handling changes. +3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on + it for anything tied to actual application shutdown. + +## Startup task class + +`Startup/{Name}StartupTask.cs` in the application project (conventional location, not enforced — +discovered by type): + +```csharp +public class MyStartupTask(ILogger logger) : BaseStartupTask(logger) +{ + public override async Task OnStartAsync(CancellationToken cancellationToken = default) + { + // one-time init — cache warm-up, external dependency check, etc. + } + + // optional — only override if needed; see the timing note below + public override async Task OnStopAsync(CancellationToken cancellationToken = default) + { + // cleanup for what OnStartAsync acquired — runs right after OnStartAsync completes, + // NOT at real application shutdown + } +} +``` + +No registration needed — every non-abstract `IStartupTask` in the entry assembly is discovered +and registered `Scoped` automatically. Any other registered service, including scoped ones, can +be injected into the constructor. + +⚠ **`OnStopAsync` is not "runs at application shutdown."** It fires immediately after every +task's `OnStartAsync` completes, as a completion/cleanup hook — not tied to real shutdown timing +(the host's real shutdown sequence may invoke it again, but that's incidental, not its purpose). +Only override it for cleanup that belongs right after this task's own startup work. + +**Execution**: all registered tasks' `OnStartAsync` run **concurrently** (`Task.WhenAll`), in one +shared scope, before the app accepts requests (API/Web) or any Console Worker starts. If [Health +Checks](nano-add-health-checks) are enabled, the app isn't reported ready until every task's +`OnStartAsync` **and** `OnStopAsync` have completed — this readiness gate applies automatically, +nothing further to wire for it. + +## After making the change + +- Show the user the file added. +- Restate step 2's consequence plainly: an unhandled exception here takes the whole app down at + startup — make sure that's the behavior actually wanted for this specific task before finishing. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-storage-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-storage-provider/SKILL.md new file mode 100644 index 00000000..34089aa4 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-storage-provider/SKILL.md @@ -0,0 +1,330 @@ +--- +name: nano-add-storage-provider +description: Add a Nano storage provider (Local or Azure) to a Nano.Library-based application - registers it in Program.cs, adds the Storage configuration section, the local docker-compose volume mount, and the Kubernetes persistent volume (plus, for Azure, the Staging/Production fileshare-provisioning CI step). Use when the user asks to add file storage, a fileshare, or a specific storage provider to a Nano API, Web, or Console application. +--- + +# Nano add storage provider + +Wires a Nano storage provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first — its `## Nano.Storage` section documents the +`Configuration` table, the provider/package table, and `IPathProvider` in full; this skill does +not repeat any of that, only how to apply it and wire the surrounding infrastructure +(docker-compose, K8s, and for Azure, CI) without breaking what's already there. + +Both providers are simpler at the code level than a data or eventing provider — per AGENTS.md, +`Local` and `Azure` both represent storage already mounted into the container's filesystem and +are accessed identically through `IPathProvider`; there's no provider-specific client/SDK to +wire into the app itself. **Everything that differs between them is infrastructure** — +docker-compose is identical either way; only the Kubernetes manifests and (for Azure) the CI +provisioning step differ. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type + names). Ask the user if not already given. +3. **Is a storage provider already registered?** Check `Program.cs` for an existing + `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation + per app, not a multi-provider case. If one exists, treat this as a replace and say so. +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for + `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the + package step. Otherwise add `` + to the **application project**, matching the version of the project's existing Nano + application-type package. Never a `ProjectReference` to Nano.Library source. + +## Program.cs + +```csharp +using Nano.Storage.Extensions; +using Nano.Storage.; +``` + +```csharp +x.AddNanoStorage<Provider>(); +``` + +`Provider` is `LocalFileShareProvider` for `Local`, `AzureFileshareProvider` for +`Azure` (see AGENTS.md's provider table for the exact names). Same `.ConfigureServices(...)` +lambda placement and `_` → `x` rename rule as the other add-provider skills. No other C# files +are needed — no context/factory equivalent, unlike the data-provider skill. + +## appsettings.json + +Add the `Storage` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`). `ShareName` isn't sensitive (it's just a name, not a +credential) — it can stay set in the base file for both providers, no Development-specific +override needed for it. + +Include `HealthCheck` only if `App:HealthCheck` is also enabled — AGENTS.md is explicit that +storage health checks do nothing without it (⚠ under `#### Health Checks`), so adding one +without the other is dead configuration. + +## docker-compose.yml (local Development) + +Identical for both providers — a bind-mounted local directory standing in for whatever the real +provider mounts in Staging/Production. Add to the app's own service in +`.docker/docker-compose.yml`: + +```yaml +volumes: + - ./bin/:/mnt/ +``` + +matching `Storage:ShareName`. No separate service container needed (unlike a data or eventing +provider) — there's nothing to run, just a directory. + +## Kubernetes — Local + +- **`.kubernetes/storage-storageclass.yaml`** (new file): + ```yaml + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: %SERVICE_NAME%-storage-class + provisioner: disk.csi.azure.com + parameters: + storageaccounttype: Standard_LRS + kind: Managed + reclaimPolicy: Retain + volumeBindingMode: WaitForFirstConsumer + ``` +- **`ReadWriteOnce`, one volume per pod, not one shared volume.** A local disk-backed volume can + only attach to a single pod — so if the app runs more than one replica (`deployment.yaml`'s + `kind: Deployment`, all replicas sharing one pod template), every replica referencing the same + static PVC name would race for the same single-attach disk; only the first pod to schedule + would mount successfully; the rest fail with a `Multi-Attach` error and never become ready. So + `Local` storage's Deployment must be a **`StatefulSet`**, using `volumeClaimTemplates` instead + of a single static `PersistentVolumeClaim` file — that gives each replica pod its own + separate, uniquely-named PVC/disk automatically. (This does mean each pod's files are + isolated from the others — a file written via one replica isn't visible from another. If the + app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, + below — its file-share CSI mount supports concurrent multi-pod access.) +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` + `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a + provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) + directory") — include `tmp` for **both** providers, it's provider-agnostic: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/%STORAGE_SHARE_NAME% + - name: tmp + mountPath: /tmp + ``` + ```yaml + volumes: + - name: tmp + emptyDir: {} + ``` + and, as a top-level sibling of `template:` (not nested inside `template.spec`) — + `volumeClaimTemplates` replaces the `PersistentVolumeClaim` file entirely: + ```yaml + volumeClaimTemplates: + - metadata: + name: %SERVICE_NAME%-volume + spec: + accessModes: + - ReadWriteOnce + storageClassName: %SERVICE_NAME%-storage-class + resources: + requests: + storage: %STORAGE_SIZE%Gi + ``` +- **`.kubernetes/service-headless.yaml`** (new file) — a `StatefulSet` requires a governing + headless service for pod network identity, separate from the app's normal `ClusterIP` service: + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: %SERVICE_NAME%-stateful-headless + namespace: %KUBERNETES_NAMESPACE% + spec: + clusterIP: None + ports: + - name: http + port: 8080 + selector: + app: %SERVICE_NAME% + ``` +- **`.kubernetes/autoscaler.yaml`** — always present on an API/Web app (per AGENTS.md's Solution + Structure), the only app types this `StatefulSet` conversion ever applies to: change its + `scaleTargetRef.kind` from `Deployment` to `StatefulSet` too — otherwise it silently targets a + resource kind that no longer exists. +- **Workflow**: add `STORAGE_SIZE` (a bare number of GB, e.g. `1000` — the template above appends + `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply + `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) + and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no + separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) — new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. + +## Kubernetes — Azure + +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` — service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) — the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in — it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite — then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists — provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. + +1. **Workflow env vars**: + ```yaml + AZURE_GROUP_STORAGE: ${{ vars.AZURE_RESOURCE_GROUP_STORAGE }} + AZURE_GROUP_BACKUP: ${{ vars.AZURE_RESOURCE_GROUP_BACKUP }} + STORAGE_SIZE: 25 + STORAGE_SHARE_NAME: + ``` +2. **Fileshare provisioning** — two steps, placed after `Managed Identity` and before + `Kubernetes Deploy`. Both are idempotent (existence-checked), safe to always include: + ```yaml + - name: Storage Role Permissions + shell: pwsh + run: | + $env:STORAGE_ACCOUNT_ID = az storage account list -g $env:AZURE_GROUP_STORAGE --query [0].id -o tsv; + + az role assignment create ` + --assignee-object-id $env:IDENTITY_PRINCIPAL_ID ` + --assignee-principal-type ServicePrincipal ` + --role "Storage File Data SMB MI Admin" ` + --scope $env:STORAGE_ACCOUNT_ID + + if ($LastExitCode -ne 0) { throw "error"; }; + + - name: Create Fileshare + shell: pwsh + run: | + $env:STORAGE_ACCOUNT_NAME = az storage account list -g $env:AZURE_GROUP_STORAGE --query [0].name -o tsv; + + $env:FILE_SHARE_EXISTS = az storage share-rm exists -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --query exists; + + if ($env:FILE_SHARE_EXISTS -eq "false") + { + az storage share-rm create -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --access-tier TransactionOptimized --quota $env:STORAGE_SIZE; + } + else + { + az storage share-rm update -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --access-tier TransactionOptimized --quota $env:STORAGE_SIZE; + } + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:BACKUP_VAULT_NAME = az backup vault list -g $env:AZURE_GROUP_BACKUP --query [0].name -o tsv; + + az backup protection enable-for-azurefileshare -g $env:AZURE_GROUP_BACKUP -v $env:BACKUP_VAULT_NAME -p $env:STORAGE_ACCOUNT_NAME-fileshare-backup-policy --storage-account $env:STORAGE_ACCOUNT_NAME --azure-file-share $env:STORAGE_SHARE_NAME; + + if ($LastExitCode -ne 0) { throw "error"; }; + + echo "STORAGE_ACCOUNT_NAME=$env:STORAGE_ACCOUNT_NAME" >> $env:GITHUB_ENV; + ``` +3. **`.kubernetes/storage-pv.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: PersistentVolume + metadata: + name: %SERVICE_NAME%-azurefile-pv-%VOLUME_NAME_SUFFIX% + spec: + capacity: + storage: %STORAGE_SIZE%Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: azurefile-static + mountOptions: + - dir_mode=0777 + - file_mode=0777 + - uid=0 + - gid=0 + claimRef: + name: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + namespace: %KUBERNETES_NAMESPACE% + csi: + driver: file.csi.azure.com + volumeHandle: %AZURE_GROUP_STORAGE%#%STORAGE_ACCOUNT_NAME%#%STORAGE_SHARE_NAME%-%VOLUME_NAME_SUFFIX% + volumeAttributes: + shareName: %STORAGE_SHARE_NAME% + storageAccount: %STORAGE_ACCOUNT_NAME% + resourceGroup: %AZURE_GROUP_STORAGE% + clientID: %IDENTITY_CLIENT_ID% + mountWithWorkloadIdentityToken: "true" + ``` +4. **`.kubernetes/storage-pvc.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + namespace: %KUBERNETES_NAMESPACE% + spec: + accessModes: + - ReadWriteMany + storageClassName: azurefile-static + resources: + requests: + storage: %STORAGE_SIZE%Gi + volumeName: %SERVICE_NAME%-azurefile-pv-%VOLUME_NAME_SUFFIX% + ``` +5. **`%VOLUME_NAME_SUFFIX%`** — derived in the `Kubernetes Deploy` step, not a static env var: + ```powershell + $env:VOLUME_NAME_SUFFIX = $env:IDENTITY_CLIENT_ID.Substring(0, 5); + ``` + placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before + `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) — new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. +6. **`.kubernetes/deployment.yaml`** — mount it (`ReadWriteMany`, so multiple replicas can share + it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/%STORAGE_SHARE_NAME% + - name: tmp + mountPath: /tmp + ``` + ```yaml + volumes: + - name: %SERVICE_NAME%-volume + persistentVolumeClaim: + claimName: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + - name: tmp + emptyDir: {} + ``` + +## After making the change + +- Show the user every file touched, grouped by concern (app code, local docker-compose, + Kubernetes, and for Azure, CI) — too many files for a flat list to be easy to sanity-check. +- If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) — don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope — flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client-configuration/SKILL.md new file mode 100644 index 00000000..8a1a73b6 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -0,0 +1,91 @@ +--- +name: nano-remove-api-client-configuration +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client — the `App:Apis` config entry and +the injection site — without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first — this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side — this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk — the + client itself has no required-service semantics beyond normal C# compilation — but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` — see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist — a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` — it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected — it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 — this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched — other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response — don't leave broken constructor parameters behind. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client/SKILL.md new file mode 100644 index 00000000..7637437d --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-api-client/SKILL.md @@ -0,0 +1,80 @@ +--- +name: nano-remove-api-client +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. +--- + +# Nano remove API client + +Deletes an Api Client's definition — the `BaseApiClient`/`BaseIdentityApiClient` subclass — from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. + +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it — those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision — point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. + +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) — point the user there; don't delete a shared definition to satisfy one consumer's request. + +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead — a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. + +## Before making any change, determine + +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class — and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all — finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding — this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost — list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 — don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going — never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** — + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. + +## Client class + +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first — a shared DTO used elsewhere should stay). + +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always — see the note above. + +## After making the change + +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time — this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup — + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-apikey/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-apikey/SKILL.md new file mode 100644 index 00000000..fecf2d2f --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-apikey/SKILL.md @@ -0,0 +1,69 @@ +--- +name: nano-remove-authentication-apikey +description: Remove Nano's built-in API-key authentication (Data:Identity:ApiKey) from a Nano.Library-based application - unregisters the ApiKey configuration and removes its Staging/Production secret/CI wiring. Use when the user asks to remove API-key authentication or the X-Api-Key header scheme from a Nano API or Web application - not for removing JWT authentication by itself, that's nano-remove-authentication-jwt. +--- + +# Nano remove API-key authentication + +Fully removes Nano's API-key authentication from an existing Nano API/Web application — the +counterpart to `nano-add-authentication-apikey`. Read that skill first — this one undoes exactly +what it adds. + +## Before making any change, determine + +1. **Is API-key authentication currently configured?** Check the base `appsettings.json` for + `Data:Identity:ApiKey:Secret`. If not present, say so and stop. +2. **Is JWT authentication also configured on this app** (`App:Authentication:Jwt`/an existing + `AuthController`)? This determines what removal actually does — surface it before proceeding: + - **Also configured**: nothing dramatic — `AuthController` doesn't depend on `ApiKeyOptions` + at all, so it keeps working exactly as before. `/auth/login/apikey` simply becomes hidden + again (`ConditionalActionsConvention` gates its visibility purely on + `Data:Identity:ApiKey:Secret`), and the scheme reverts from `JWT_OR_APIKEY` to JWT-only. No + file besides config/K8s/CI needs touching. + - **Not configured (pure API-key mode)**: this was the app's **only** authentication scheme — + removing it leaves the app with no authentication at all, every endpoint anonymous by + default (AGENTS.md). Confirm this is intended before proceeding; it's a security-relevant + change, not just a config cleanup, and there's no controller here to hint at it either (pure + API-key mode never had one). + +## appsettings.json + +Remove `Data:Identity:ApiKey:Secret` from the base `appsettings.json`, and from +`appsettings.Development.json` too if a local convenience value was set there (per +`nano-add-authentication-apikey`'s note that this is the one place a Development override might +exist, unlike the shared JWT key pair). + +## Kubernetes / GitHub Actions + +Unlike `auth-jwt-secret`, this secret is always per-app (never shared across services), so +there's no issuer/validator distinction to worry about here — always safe to remove: + +- Delete `.kubernetes/auth-api-key-secret.yaml`. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `AUTH_API_KEY_SECRET` workflow env var. +- Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s + container `env`. + +⚠ This does **not** delete either underlying live resource — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) — removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) — this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + +## After making the change + +- Show the user every file touched/deleted. +- Restate step 2's outcome now that it's done — either "JWT auth still works, the key-exchange + endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" — + whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above — the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-jwt/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-jwt/SKILL.md new file mode 100644 index 00000000..2a73804f --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-jwt/SKILL.md @@ -0,0 +1,148 @@ +--- +name: nano-remove-authentication-jwt +description: Remove Nano's built-in JWT authentication (App:Authentication:Jwt) from a Nano.Library-based application - unregisters the Jwt configuration across every environment, deletes the AuthController, and removes the Staging/Production key secret/CI wiring. Use when the user asks to remove login, sign-in, or JWT authentication from a Nano API or Web application - not for removing API-key authentication by itself, that's nano-remove-authentication-apikey. +--- + +# Nano remove JWT authentication + +Fully removes Nano's JWT authentication from an existing Nano API/Web application — the +counterpart to `nano-add-authentication-jwt`. Read that skill first — this one undoes exactly +what it adds. + +**The `AuthController` cannot survive this removal, ever — not a judgment call.** +`BaseAuthController`'s constructor requires `IAuthRepository` as a non-nullable parameter, and +`IAuthRepository` is only registered when `Jwt != null` (`AddNanoAuthentication`). Once `Jwt` is +gone, the controller fails DI resolution at startup if left in place. Delete it unconditionally, +even if API-key auth stays configured afterward — see step 3. + +## Before making any change, determine + +1. **Is JWT authentication currently configured?** Check the base `appsettings.json` for + `App:Authentication:Jwt`, or an existing `AuthController`. If neither exists, say so and stop. +2. **Is this app the token issuer or a validator-only app?** Check `.kubernetes/deployment.yaml` + for whether it maps `App__Authentication__Jwt__PrivateKey` (issuer) or `PublicKey` only + (validator) — this determines which Kubernetes/CI cleanup applies below. +3. **Is API-key authentication also configured** (`Data:Identity:ApiKey:Secret` set)? This + changes what removing `Jwt` actually does to the app, and needs surfacing before proceeding: + - **Not configured**: this app ends up with no authentication at all — every endpoint becomes + anonymous by default (AGENTS.md: "If no authentication schemes has been configured, all + endpoints will be accessible anonymously"). Confirm this is intended before proceeding; it's + a real security-relevant change, not just a code cleanup. + - **Also configured**: the app doesn't lose authentication — it reverts to **pure API-key + mode** (per `nano-add-authentication-apikey`), since `ApiKeyAuthenticationHandler` doesn't + depend on `Jwt` at all. The `AuthController` still gets deleted (per the note above), and + `/auth/login/apikey` disappears with it — API-key callers keep working unchanged, but lose + the "trade a key for a JWT once" convenience. Tell the user this explicitly; don't let it + read as a side effect they weren't told about. +4. **Custom controller logic?** Open `AuthController.cs` before deleting it — if it's still just + the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added + custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries — don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone — they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` — but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked — they may hold real integration logic worth keeping if `Jwt` + comes back later — but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too — per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these — the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too — don't assume config-file removal alone caught it. + +## appsettings.json + +Remove `App:Authentication:Jwt` (the whole object, including `RootLogin`/`ExternalLogins` if +present) from the base `appsettings.json`, `appsettings.Development.json`, +`appsettings.Staging.json`, and `appsettings.Production.json` — every environment file that has +it, per `nano-add-authentication-jwt`'s placement (`Issuer`/`Audience` overrides live in every +environment file, not just Development). + +## AuthController + +Delete `Controllers/AuthController.cs` — see the note at the top; this isn't conditional. + +## Kubernetes / GitHub Actions — issuer app only + +If step 2 found this app is the issuer: + +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block — `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. +- If this app was the **only** issuer in the solution, every validator-only app that references + `auth-jwt-secret` now points at a secret nothing creates anymore — flag this to the user + explicitly; it's outside this skill's scope (a different app's files), but silently leaving it + broken elsewhere is worse than mentioning it. + +⚠ This does **not** delete either underlying live resource — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working — don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) — removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) — this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + +## Kubernetes — every app (issuer and validator) + +Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries +(whichever are present — a validator only ever has `PublicKey`) from +`.kubernetes/deployment.yaml`'s container `env`. + +## Kubernetes / GitHub Actions — RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + +## After making the change + +- Show the user every file touched/deleted. +- Restate step 3's outcome one more time now that it's actually done — either "this app now has + no authentication, every endpoint is anonymous" or "this app is now in pure API-key mode, the + JWT exchange endpoint is gone" — whichever applies. This is the one thing most worth a second, + explicit confirmation rather than folding into a file list. +- If step 2 found this app was the sole issuer, restate the warning about now-broken + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster — only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) — this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later — + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed — there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-availability-check/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-availability-check/SKILL.md new file mode 100644 index 00000000..4bb30a2a --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-availability-check/SKILL.md @@ -0,0 +1,34 @@ +--- +name: nano-remove-availability-check +description: Remove availability monitoring from a Nano API or Web application - removes the CI step that creates/maintains the Azure Application Insights ping test and alert. Use when the user asks to remove availability monitoring, an uptime check, or a ping test from a Nano application. +--- + +# Nano remove availability check + +Removes availability monitoring from an existing Nano application — the counterpart to +`nano-add-availability-check`. + +## Before making any change, determine + +1. **Is Availability Check currently configured?** Check the workflow for an "Add Availability + Check" step. If absent, say so and stop. + +## GitHub Actions + +Remove the "Add Availability Check" step entirely, and `AZURE_GROUP_LOGS` if nothing else in the +workflow still references it. + +⚠ This does **not** delete the underlying Azure resources (the Application Insights web test and +its metric alert) — those are real Azure resources this step only creates/maintains +idempotently, it never owned their lifecycle for deletion. Removing the workflow step just stops +maintaining them going forward; the ping test keeps running (and could keep alerting) until +someone deletes it directly in Azure (`az monitor app-insights web-test delete` / removing the +alert resource). Say this explicitly rather than implying the monitoring stops the moment the +workflow step is removed. + +## After making the change + +- Show the user the workflow change. +- Restate the ⚠ above — the Azure-side resources need manual cleanup if the user actually wants + the monitoring (and its alerts) to stop, not just future deploys to skip maintaining it. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-azure-managed-identity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-azure-managed-identity/SKILL.md new file mode 100644 index 00000000..074df719 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-azure-managed-identity/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-azure-managed-identity +description: Remove Azure Managed Identity / Kubernetes Workload Identity wiring from a Nano.Library-based application - deletes service-account.yaml, the workload-identity pod annotations, and the CI "Managed Identity" step. Use when the user asks to remove Managed Identity or Workload Identity from a Nano API, Web, or Console application - checks first whether a Data or Storage provider still depends on it. +--- + +# Nano remove managed identity + +Fully removes Azure Managed Identity / Kubernetes Workload Identity wiring from an existing Nano +API, Web, or Console application — the counterpart to `nano-add-azure-managed-identity`. Read that +skill first — this one undoes exactly what it adds. + +## Before making any change, determine + +1. **Is Managed Identity currently wired?** Check for `.kubernetes/service-account.yaml` and the + `Managed Identity` workflow step. If neither exists, say so and stop. +2. **What depends on it?** Two genuinely different situations — check both, and don't treat them + the same: + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** — per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry — the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` — nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` — but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. + - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). + Unlike Data, there's **no credentials-based fallback for Storage** — per AGENTS.md's + `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's + file-share CSI mount is unconditionally Workload-Identity-authenticated + (`mountWithWorkloadIdentityToken: "true"`). If Azure storage is in use, Managed Identity + **cannot** be removed without breaking it outright — the only ways forward are switching to + `Local` storage (`nano-remove-storage-provider` then `nano-add-storage-provider` with + `Local`) or removing storage entirely first. Tell the user this plainly and stop; don't + proceed with Managed Identity removal while Azure storage still depends on it. + If neither applies, proceed normally. + +## Kubernetes + +- Delete `.kubernetes/service-account.yaml`. +- Remove the `azure.workload.identity/use: "true"` label and `serviceAccountName` field from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s pod template. +- Remove the `service-account.yaml` apply block from the `Kubernetes Deploy` workflow step. + +## GitHub Actions + +Remove the `Managed Identity` step entirely. Leave `AZURE_GROUP_KUBERNETES` alone — every app's +workflow needs it independently for basic AKS deploy (`az aks get-credentials`), regardless of +Managed Identity. + +⚠ This does **not** delete the underlying Azure user-assigned identity or its federated +credential in Azure itself (`az identity delete`) — that's a real Azure resource this skill +doesn't provision or own the lifecycle of. Removing the workflow step just stops maintaining it +going forward; say so explicitly rather than implying the Azure-side resource is gone too. + +## After making the change + +- Show the user every file touched/deleted. +- Restate whatever was flagged in step 2 — the Data provider reverted to `Credentials` (and what + that required), or the fact that Storage blocked the whole removal — one more time here. +- If step 1 or step 2's Storage case stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-console-worker/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-console-worker/SKILL.md new file mode 100644 index 00000000..d0ba5037 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-console-worker/SKILL.md @@ -0,0 +1,28 @@ +--- +name: nano-remove-console-worker +description: Remove a Console Worker from a Nano Console application - deletes the BaseWorker-derived class. Use when the user asks to remove a worker, background job, or a specific task from a Nano Console application. +--- + +# Nano remove console worker + +Removes a Console Worker from an existing Nano Console application — the counterpart to +`nano-add-console-worker`. + +## Before making any change, determine + +1. **Which worker?** Confirm the class name/file if the project has more than one — check + `Workers/` (or search for `BaseWorker`/`IWorker` if not in the conventional location). +2. **Is this the last worker in the app?** Not a blocker — a Console app with zero workers still + runs (Startup Tasks, if any, still execute), it just does nothing beyond that. Worth + mentioning if it leaves the app with no actual job. + +## Worker class + +Delete the file. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change. + +## After making the change + +- Show the user the file removed. +- If step 2 applies (last worker removed), say so explicitly — the app will still start and run + its Startup Tasks, but do nothing further. 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 new file mode 100644 index 00000000..cecab0a4 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-custom-endpoint/SKILL.md @@ -0,0 +1,196 @@ +--- +name: nano-remove-custom-endpoint +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller — "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes — + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** — confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` — see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape — see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" — see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away — see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) — if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first — cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field — don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` — only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too — an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this — that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call — its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers — say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above — a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO — only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` — only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change — this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower — "just remove the Api Client method, keep the +controller action" or vice versa — that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused — it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits — + no selective `$expand`, and `[Include]` being global rather than per-caller — before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints — other internal + services, other Public APIs — becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) — see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" — it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization — not just validation derivable from the entity/data itself + — that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` — override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal — including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 — this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-data-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-data-provider/SKILL.md new file mode 100644 index 00000000..1e5c0814 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-data-provider/SKILL.md @@ -0,0 +1,142 @@ +--- +name: nano-remove-data-provider +description: Remove a Nano data provider (MySql, PostgreSQL, SqlServer, SqLite, or InMemory) from a Nano.Library-based application - unregisters it in Program.cs and removes the DbContext/DbContextFactory, Data configuration, docker-compose database service, and (for MySql/PostgreSQL/SqlServer) the Staging/Production migration CI step and Kubernetes secret, or (for SqLite) the persistent volume. Use when the user asks to remove a database, drop persistence, or strip a data provider out of a Nano API, Web, or Console application. +--- + +# Nano remove data provider + +Fully removes a Nano data provider from an existing Nano API, Web, or Console application — the +counterpart to `nano-add-data-provider`. Read that skill first — this one undoes exactly what it +adds, file for file; refer back to it for the shapes/locations of anything unclear here rather +than re-deriving them. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoData()`. If none, say so and stop. +2. **What depends on it?** This is the step most worth getting right — removing a Data provider + out from under dependent features leaves them broken, not just unused. As with eventing, the + real risk here is a startup crash, not just dead code — `IRepository` and the concrete + `DbContext` are both registered by `AddNanoData()` (AGENTS.md's + `### Repositories` section), and any class with either as a **required** constructor + parameter fails DI resolution the instant the provider is gone: + - **`IRepository` or the `DbContext` injected directly.** Search the project for both, + anywhere — controllers, services, workers. A scaffolded controller + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, + so any existing entity's controller is a guaranteed hit — the app won't start at all with + it left in place and the provider gone. + - **Data Mappings — a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) — a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone — deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** — same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) — dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them — not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. + - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) + or a JWT auth setup depends on the Identity store this context provides, removing the + provider breaks authentication entirely. + If any of these apply, tell the user exactly what removing the provider will do (crash vs. + dead code) and confirm before proceeding — don't remove out from under them without saying so. +3. **Is the package reference this skill's to remove?** Same check as the logging-remove skill: + if the project references `NanoCore`/`Nano.All`, leave it alone (it covers unrelated + features too). Otherwise remove the `Nano.Data.` `PackageReference` from the + application project. + +## Program.cs + +Remove the `using Nano.Data.Extensions;`, `using Nano.Data.;`, and `using +.Data;` lines, and the `.AddNanoData<...>()` call. Same empty-lambda cleanup rule as +the logging-remove skill: if nothing else is left in `.ConfigureServices(...)`, restore the +blank-app placeholder and the `_` discard parameter. + +## Files to delete + +- `Data/DbContext.cs` +- `Data/DbContextFactory.cs` (if present — `InMemory` never had one) +- `Migrations/` folder (if present — dead without the factory that constructs the context for + `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) — required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it — + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave — flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. + +## appsettings.json + +Remove the `Data` section entirely from the base `appsettings.json`, and from +`appsettings.Development.json` if it has its own `Data` override there (the common case — see +`nano-add-data-provider`'s placement rules for what that override normally contains). + +**Exception — SqLite**: its `Data` section lives entirely in the base file (no Development +override, per `nano-add-data-provider`'s SqLite section) — remove it from there instead. + +## docker-compose.yml + +Delete the `database` service block entirely (don't comment it out — `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` — neither ever had a `database` service. + +## SqLite-specific cleanup + +If the provider was `SqLite`, additionally: +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete — `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the + `Kubernetes Deploy` workflow step. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains — and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. +- Remove the `SQL_SIZE` workflow env var, if nothing else uses it. + +## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) + +Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). + +1. **Workflow steps** — remove ` Database Migration` (this is the only migration step + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** — remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove — + `nano-add-data-provider` doesn't add one). Only remove + `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists — check before removing). +3. **Kubernetes secret** — delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block + from the `Kubernetes Deploy` step. +4. **ConfigMap** — remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from + `.kubernetes/configmap.yaml`. +5. **Deployment** — remove the `Data__ConnectionString` `secretKeyRef` entry from + `.kubernetes/deployment.yaml`'s container `env`. + +## After making the change + +- Show the user every file touched/deleted, grouped by concern (app code, local docker-compose, + Staging/Production CI + K8s) — same reasoning as the add skill: too many files for a flat list + to be easy to sanity-check. +- Restate anything flagged in step 2 — required `IRepository`/`DbContext` injections that will + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth — one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ + Production section never existed to begin with, say so explicitly. 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 new file mode 100644 index 00000000..a938a28e --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-entity/SKILL.md @@ -0,0 +1,96 @@ +--- +name: nano-remove-entity +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity — data model, EF Core mapping, +query criteria, and CRUD controller — as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" — a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping — an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table — so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists — the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it — + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** — this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it — their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity — don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** — the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side — but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned — + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) — check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) — check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) — not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published — this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome — whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response — don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-event-handler/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-eventing-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-eventing-provider/SKILL.md new file mode 100644 index 00000000..dca2682e --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-eventing-provider/SKILL.md @@ -0,0 +1,80 @@ +--- +name: nano-remove-eventing-provider +description: Remove a Nano eventing provider (currently only RabbitMq) from a Nano.Library-based application - unregisters it in Program.cs and removes the Eventing configuration, local docker-compose broker service, and Kubernetes secret reference. Use when the user asks to remove eventing, pub/sub messaging, or a message broker from a Nano API, Web, or Console application. +--- + +# Nano remove eventing provider + +Fully removes a Nano eventing provider from an existing Nano API, Web, or Console application — +the counterpart to `nano-add-eventing-provider`. Read that skill first — this one undoes exactly +what it adds. + +## Before making any change, determine + +1. **Is an eventing provider currently registered?** Check `Program.cs` for + `.AddNanoEventing<...>()`. If none, say so and stop. +2. **What depends on it?** Two distinct risks, different severities — check for both: + - **Startup crash.** Search for `IEventing` used as a constructor parameter (injected via DI) + anywhere in the project, and check whether it's required or optional (`IEventing? + eventing`, the pattern the entity-scaffold skill uses when an eventing provider isn't + registered). A class with a **required** `IEventing` parameter fails DI resolution the + moment the provider is gone — the app won't start at all, not even a runtime error deep in + some request path. This is the more urgent of the two checks. + - **Silent no-op.** Per AGENTS.md's `### Entity Events` section, `[Publish]`/`[Subscribe]` + entity replication **requires Eventing configured, and silently does nothing without it** — + no exception, no error, it just stops syncing. Also check for any class deriving + `BaseEventHandler` (general pub/sub, not entity events) — its handler simply never + fires again, with no signal that it stopped. + If either exists, tell the user exactly what removing the provider will do to it (crash vs. + silent no-op) and confirm before proceeding — don't remove out from under them without saying + so. +3. **Is the package reference this skill's to remove?** Same check as the other remove skills: + leave `NanoCore`/`Nano.All` alone if present; otherwise remove the `Nano.Eventing.RabbitMq` + `PackageReference` from the application project. + +## Program.cs + +Remove `using Nano.Eventing.Extensions;`, `using Nano.Eventing.RabbitMq;`, and the +`.AddNanoEventing<...>()` call. Same empty-lambda cleanup as the other remove-provider skills: +restore the blank-app placeholder and `_` parameter if nothing else is left in +`.ConfigureServices(...)`. + +## appsettings.json + +Remove the `Eventing` section from the base `appsettings.json`, and its `Credentials` override +from `appsettings.Development.json` if present (per `nano-add-eventing-provider`'s placement — +only `Credentials` lives in the Development file, the rest of the section is base-only). + +## docker-compose.yml + +Remove the `eventing` service from `.docker/docker-compose.yml` entirely (delete, don't +comment out — unlike the data-provider skill's multiple-alternatives convention, there's only +one eventing provider, so there's no sibling variant worth preserving as a reference). Also +remove it from the app's own service's `depends_on` list. + +## Existing entity controllers + +The counterpart to `nano-add-eventing-provider`'s retrofit step: remove the `IEventing? eventing` +constructor parameter (and the corresponding base-constructor argument) from every entity +controller that has one, across the full `BaseEntity*Controller`/`BaseEntityUserController` +hierarchy — it's dead weight once nothing can ever populate it. This is separate from, and safe +regardless of, the crash risk already flagged in step 2: a controller with the **nullable** +`IEventing?` form just loses an unused parameter here; one with a **required** `IEventing` +parameter (the crash case) still needs that constructor fixed by hand as part of addressing step +2 — removing the parameter here is what actually resolves it, once the user has confirmed that's +acceptable. + +## Kubernetes + +Remove the four `Eventing__*` env entries (`Eventing__Host`, `Eventing__Port`, +`Eventing__Credentials__Id`, `Eventing__Credentials__Secret`) from +`.kubernetes/deployment.yaml`'s container `env`. There's no secret file to delete — the +`rabbitmq-default-user` secret is shared/cluster-wide and outlives this app regardless. + +## After making the change + +- Show the user every file touched, including every controller that had `IEventing? eventing` + removed, and restate anything flagged in step 2 — required `IEventing` injections that will now + crash the app, plus any orphaned `[Publish]`/`[Subscribe]` entities or dead event handlers — one + more time now that the removal is actually done, not just as the earlier confirmation. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, say so explicitly. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-health-checks/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-health-checks/SKILL.md new file mode 100644 index 00000000..785b2532 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-health-checks/SKILL.md @@ -0,0 +1,41 @@ +--- +name: nano-remove-health-checks +description: Remove Nano's built-in health checks (App:HealthCheck) from a Nano API or Web application - removes the config and the Kubernetes liveness/readiness probes together, since leaving one without the other breaks the pod. Use when the user asks to remove health checks or the /healthz endpoint from a Nano API or Web application. +--- + +# Nano remove health checks + +Removes Nano's `/healthz` endpoint from an existing Nano API or Web application — the counterpart +to `nano-add-health-checks`. Read that skill first — this one undoes exactly what it adds, and +the same "never do one half without the other" rule applies in reverse here. + +## Before making any change, determine + +1. **Is `App:HealthCheck` currently configured?** Check the base `appsettings.json`. If absent, + say so and stop. +2. **What depends on it?** + - **Kubernetes probes will start failing if left behind.** Removing `App:HealthCheck` without + also removing the `livenessProbe`/`readinessProbe` in `deployment.yaml`/`stateful-set.yaml` + means Kubernetes keeps probing a `/healthz` path that no longer exists — the pod gets marked + unhealthy and crash-loops. Both must be removed together; this is not optional cleanup. + - **Provider health checks go dark, not broken.** If `Data`/`Eventing`/`Storage`/ + `App:Apis:{Client}`'s own `HealthCheck` blocks are configured, they become dead config once + `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a + crash, but tell the user — leaving those blocks in place with no effect is confusing without + an explanation. + +## Kubernetes + +Remove the `livenessProbe` and `readinessProbe` entries from `.kubernetes/deployment.yaml`'s (or +`stateful-set.yaml`'s) container spec. + +## appsettings.json + +Remove `App:HealthCheck` from the base `appsettings.json`. + +## After making the change + +- Show the user every file touched. +- Restate step 2's provider-health-check note if applicable — which blocks are now dead config, + without functional effect. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-identity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-identity/SKILL.md new file mode 100644 index 00000000..bc736b11 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-identity/SKILL.md @@ -0,0 +1,124 @@ +--- +name: nano-remove-identity +description: Remove Nano's persistent Identity store (Data:Identity) from a Nano.Library-based application - unregisters the Identity configuration and deletes the User entity/mapping/controller triplet Nano's identity actions attached to. Use when the user asks to remove user accounts, the user store, or persistent identity from a Nano API, Web, or Console application - not for removing authentication/login itself, that's nano-remove-authentication-jwt/nano-remove-authentication-apikey. +--- + +# Nano remove identity + +Fully removes Nano's persistent Identity store from an existing Nano API, Web, or Console +application — the counterpart to `nano-add-identity`. Read that skill first — this one undoes +exactly what it adds. + +## Before making any change, determine + +1. **Is Identity currently configured?** Check the base `appsettings.json` for `Data:Identity`, + and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. If neither + exists, say so and stop. +2. **What depends on it?** Two distinct risks, different severities — check for both: + - **Startup crash.** Search for `IIdentityRepository`/`IIdentityRepository` used as + a **required** constructor parameter anywhere in the project. This is a guaranteed hit, not + a maybe: the identity entity's own controller (`nano-add-identity`'s own template) always + takes it as a required parameter, so that controller crashes DI resolution the instant + Identity is gone — the app won't start at all, same class of failure as the Data-provider + and Eventing removal skills' crash checks. + - **Authentication degrades, doesn't crash — but is worth flagging just as clearly.** If + `App:Authentication:Jwt` is configured on this app (`nano-add-authentication-jwt`), removing + Identity silently drops `AuthIdentityRepository` back to `null` (AGENTS.md's sub-repository + table: populated only when Identity is configured) — `/auth/login`, `/auth/login/refresh`, + and `/auth/logout` stop being registered, no exception, they just disappear. If + `Data:Identity:ApiKey:Secret` is configured (`nano-add-authentication-apikey`), it's removed + along with the rest of `Data:Identity` (it's a child of it) — API-key auth disappears + entirely, including `/auth/login/apikey` if that was in use. Neither of these crashes the + app, but both are significant behavior changes on an app that may have real callers — this + skill does not touch `App:Authentication` itself, so if the user also wants Authentication + removed, point them at `nano-remove-authentication-jwt`/`nano-remove-authentication-apikey` + rather than leaving it half-configured and pointing at nothing. + If either applies, tell the user exactly what removing Identity will do (crash vs. silent + endpoint loss) and confirm before proceeding — don't remove out from under them without saying + so. +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later — either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead — the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one — check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it — full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** — ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate + NuGet package, so there's nothing to remove from the `.csproj` here either. + +## appsettings.json + +Remove the `Data:Identity` section entirely from the base `appsettings.json`. There's no +`appsettings.Development.json` override to also clean up — per `nano-add-identity`, the whole +section lives in the base file only. + +## User entity, mapping, and controller + +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: + +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: +- `Data/.cs` (or the `.Models` project in a split layout). +- `Data/Mappings/Mapping.cs`. +- `Criterias/QueryCriteria.cs` (API/Web only — never existed for Console). +- `Controllers/sController.cs` (API/Web only). + +**Has custom content the user wants kept:** convert in place instead of deleting — the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` — nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` — this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 — converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched — nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added — keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists — that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way — the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. + +## After making the change + +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. +- Restate anything flagged in step 2 — the guaranteed controller crash, plus the specific + Authentication endpoints that silently disappear if Jwt/API-key auth was configured — one more + time here, even if the user already confirmed it. +- If the user also wants Authentication removed, say explicitly that this skill didn't touch it + and point them at `nano-remove-authentication-jwt`/`nano-remove-authentication-apikey`. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-logging-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-logging-provider/SKILL.md new file mode 100644 index 00000000..a36ab6a9 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-logging-provider/SKILL.md @@ -0,0 +1,54 @@ +--- +name: nano-remove-logging-provider +description: Remove a Nano logging provider (Log4Net, Microsoft, NLog, or Serilog) from a Nano.Library-based application - unregisters it in Program.cs and removes the Logging configuration section from appsettings.json. Use when the user asks to remove logging, unregister the logging provider, or strip logging out of a Nano API, Web, or Console application. +--- + +# Nano remove logging provider + +Fully removes Nano logging from an existing Nano API, Web, or Console application — the +counterpart to `nano-add-logging-provider`. If the user actually wants to *switch* to a +different provider, that's the add skill's job (it already handles replacing an existing +provider); use this skill only when the end state should be no Nano logging provider +registered at all. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoLogging<...>()` + — the type argument tells you which provider. If none is registered, say so and stop; there's + nothing to remove. +2. **Is the package reference this skill's to remove?** Look for a `PackageReference` to + `Nano.Logging.` on the application project. If found (the project uses the + explicit/granular convention), remove it. + - If instead the project references `NanoCore` or `Nano.All` (the "quick start" convention — + see AGENTS.md), **leave it alone** — that package covers every Nano feature the project + uses, not just logging, so removing it would break unrelated functionality. There's simply + no package-level change to make in that case. + +## Program.cs + +Remove the `using Nano.Logging.Extensions;` and `using Nano.Logging.;` lines, and the +`.AddNanoLogging<...>()` call inside `.ConfigureServices(...)`. + +- If the lambda has no other statements left after removing the call, restore it to the + standard blank-app placeholder shape and rename the parameter back to the discard `_`: + `.ConfigureServices(_ => { // Add your services here. })`. Leaving an empty non-discard + parameter or a bare empty block behind looks like an unfinished edit. +- If other real service registrations remain in the lambda, just remove the one line — don't + touch the rest, and keep the parameter as `x`. + +## appsettings.json + +Remove the `Logging` section from the base `appsettings.json` entirely — it's a sibling of +`App`, added by the add-skill's `AGENTS.md`-documented shape. With no provider registered, +`LogLevel`/`LogLevelOverrides` are dead configuration nothing reads (the same class of bug as +leaving a Kubernetes probe pointed at a `HealthCheck` that was never enabled — don't leave it +behind). + +## After making the change + +- Show the user the modified `Program.cs` lines, the removed `appsettings.json` section, and — + if one was removed — the `PackageReference` taken out of the `.csproj`. +- If nothing needed to change at the package level because the project uses `NanoCore`/ + `Nano.All`, say so explicitly rather than leaving it unmentioned. +- Don't touch Docker/Kubernetes/CI files — logging provider selection has no effect on any of + those. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-metrics/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-metrics/SKILL.md new file mode 100644 index 00000000..f91c614b --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-metrics/SKILL.md @@ -0,0 +1,36 @@ +--- +name: nano-remove-metrics +description: Remove Nano's built-in OpenTelemetry metrics (App:Metrics) from a Nano API or Web application - removes the config and the Kubernetes ServiceMonitor. Use when the user asks to remove metrics, Prometheus, OpenTelemetry, or the /metrics endpoint from a Nano API or Web application. +--- + +# Nano remove metrics + +Removes Nano's `/metrics` endpoint from an existing Nano API or Web application — the counterpart +to `nano-add-metrics`. + +## Before making any change, determine + +1. **Is `App:Metrics` currently configured?** Check the base `appsettings.json`. If absent, say + so and stop. +2. **What depends on it?** Nothing in the app itself — Metrics has no dependents (confirmed + against `nano-add-metrics`'s own verification that it's independent of Health Checks, and + nothing else in the framework reads `App:Metrics`). The only external dependent is whatever + scrapes it — if Prometheus/Grafana dashboards are actively built on this endpoint, removing it + silently breaks that monitoring, with no error on the app side. Ask before removing if that + seems likely, since this app has no way to know it's being scraped. + +## Kubernetes + +Delete `.kubernetes/service-monitor.yaml`, and remove its apply block from the `Kubernetes +Deploy` workflow step. + +## appsettings.json + +Remove `App:Metrics` from the base `appsettings.json`. + +## After making the change + +- Show the user every file touched/deleted. +- If step 2's external-scraping concern applies, restate it — removing this leaves no error + anywhere in the app, only a monitoring dashboard that goes quiet. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-public-exposure/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-public-exposure/SKILL.md new file mode 100644 index 00000000..fd129a3a --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-public-exposure/SKILL.md @@ -0,0 +1,47 @@ +--- +name: nano-remove-public-exposure +description: Remove public exposure from a Nano API or Web application - removes the HTTPS hosting config, Kubernetes HTTPRoute resources, and the CI hostname-derivation step. Cascades to removing Availability Check first, since that depends entirely on the app being publicly reachable. Use when the user asks to remove public exposure, take a Nano application private, or remove an HTTPRoute. +--- + +# Nano remove public exposure + +Removes public reachability from an existing Nano API or Web application — the counterpart to +`nano-add-public-exposure`. + +## Before making any change, determine + +1. **Is the app currently publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ + `httproute-443.yaml`. If neither exists, say so and stop. +2. **Is Availability Check configured?** Check the workflow for an "Add Availability Check" step. + **If so, this must be removed first, not left behind** — per `nano-add-availability-check`, + its ping test hits `https://$SUB_DOMAIN_NAME.$zoneName/healthz`, which stops resolving the + moment the `HTTPRoute`s are gone; leaving the check in place means it starts firing failure + alerts for an app that was deliberately taken private, not one that's actually down. Run + `nano-remove-availability-check` first, then continue with this skill — don't ask, this + cascade is the expected behavior, but tell the user it happened. + +## Kubernetes + +Delete `.kubernetes/httproute-80.yaml` and `.kubernetes/httproute-443.yaml`. `service.yaml` is +unaffected — it never needed to change to add exposure, so it doesn't need to change to remove +it either. + +## GitHub Actions + +Remove the `SUB_DOMAIN_NAME`/`AZURE_GROUP_DNS` env vars (unless Availability Check's own removal +already handled `AZURE_GROUP_DNS` — don't remove it twice or assume it's still needed elsewhere +without checking), the `$env:ROUTE_HOST_NAMES`/`$env:GATEWAY_NAME` derivation step, and the +`httproute-80.yaml`/`httproute-443.yaml` apply blocks from `Kubernetes Deploy`. + +## appsettings.json / docker-compose.yml + +Remove the `App:Hosting:Https`/`UseHttpsRedirection` block from `appsettings.Development.json`, +and the HTTPS port mapping + certificate volume from `docker-compose.yml`. Leave the base +`appsettings.json` alone — it was never changed by the add skill (HTTP stays exposed regardless). + +## After making the change + +- Show the user every file touched/deleted. +- If step 2's cascade applied, restate clearly that Availability Check was removed as a + consequence, not a separate request. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-startup-task/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-startup-task/SKILL.md new file mode 100644 index 00000000..4328302a --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-startup-task/SKILL.md @@ -0,0 +1,32 @@ +--- +name: nano-remove-startup-task +description: Remove a Startup Task from a Nano application - deletes the BaseStartupTask-derived class. Use when the user asks to remove a startup task, cache warm-up, or one-time initialization from a Nano API, Web, or Console application. +--- + +# Nano remove startup task + +Removes a Startup Task from an existing Nano API, Web, or Console application — the counterpart +to `nano-add-startup-task`. + +## Before making any change, determine + +1. **Which task?** Confirm the class name/file if the project has more than one — check + `Startup/` (or search for `BaseStartupTask`/`IStartupTask` if not in the conventional + location). +2. **Behavior change to flag, not a crash risk.** Nothing else in the app takes a required + dependency on a startup task's existence, so removal never breaks compilation or DI. The one + real effect: if [Health Checks](nano-add-health-checks) are enabled, the app's readiness gate + no longer waits on whatever this task was checking/warming — readiness becomes available + sooner, and whatever the task guaranteed (a warm cache, a verified dependency) is no longer + guaranteed before traffic is accepted. Tell the user this plainly if it seems load-bearing. + +## Startup task class + +Delete the file. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change. + +## After making the change + +- Show the user the file removed. +- Restate step 2 if the removed task looked like it was guarding something meaningful (an + external dependency check, a required warm-up) rather than being purely cosmetic. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-remove-storage-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-storage-provider/SKILL.md new file mode 100644 index 00000000..60052514 --- /dev/null +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-remove-storage-provider/SKILL.md @@ -0,0 +1,88 @@ +--- +name: nano-remove-storage-provider +description: Remove a Nano storage provider (Local or Azure) from a Nano.Library-based application - unregisters it in Program.cs and removes the Storage configuration, local docker-compose volume mount, and the Kubernetes persistent volume (plus, for Azure, the Staging/Production fileshare-provisioning CI step). Use when the user asks to remove file storage, a fileshare, or a specific storage provider from a Nano API, Web, or Console application. +--- + +# Nano remove storage provider + +Fully removes a Nano storage provider from an existing Nano API, Web, or Console application — +the counterpart to `nano-add-storage-provider`. Read that skill first — this one undoes exactly +what it adds, file for file. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoStorage<...>()`. + If none, say so and stop. +2. **What depends on it?** Per AGENTS.md's `## Nano.Storage` section, registering a provider also + registers `IPathProvider`, "injectable anywhere" — search the project for it used as a + constructor parameter. A **required** `IPathProvider` parameter fails DI resolution the moment + the provider is gone — the app won't start at all. This is the only dependency risk here: + unlike Eventing, there's no declarative attribute (`[Publish]`/`[Subscribe]`) tied to storage + that would silently stop working instead — anything using it does so explicitly, in code. If a + required injection exists, tell the user removing the provider will crash the app there and + confirm before proceeding. +3. **Is the package reference this skill's to remove?** Same check as the other remove skills: + leave `NanoCore`/`Nano.All` alone if present; otherwise remove the `Nano.Storage.` + `PackageReference` from the application project. + +## Program.cs + +Remove `using Nano.Storage.Extensions;`, `using Nano.Storage.;`, and the +`.AddNanoStorage<...>()` call. Same empty-lambda cleanup as the other remove-provider skills: +restore the blank-app placeholder and `_` parameter if nothing else is left in +`.ConfigureServices(...)`. + +## appsettings.json + +Remove the `Storage` section entirely from the base `appsettings.json`. There's no +Development-specific override to also clean up — per `nano-add-storage-provider`, `ShareName` +isn't sensitive and stays in the base file only, for both providers. + +## docker-compose.yml + +Remove the `volumes` entry mapping `./bin/:/mnt/` from the app's own +service in `.docker/docker-compose.yml`. Unlike a data or eventing provider, storage never added +a separate service container — just this one volume line — so there's nothing else to remove +here. + +## Kubernetes — Local + +- Delete `.kubernetes/storage-storageclass.yaml` and `.kubernetes/service-headless.yaml`. +- If the app was converted to a `StatefulSet` for this provider (`.kubernetes/stateful-set.yaml` + present, `serviceName: %SERVICE_NAME%-stateful-headless` set), revert it to a plain `Deployment`: + rename the file back to `deployment.yaml`, change `kind: StatefulSet` → `kind: Deployment`, + remove the `serviceName` field, and remove the `volumeClaimTemplates` block (there's no static + `PersistentVolumeClaim` file to restore in its place — the volume is gone entirely, not + replaced). `.kubernetes/autoscaler.yaml` is always present on an API/Web app — if its + `scaleTargetRef.kind` was changed to `StatefulSet` (i.e. the app was converted per the above), + change it back to `Deployment`. +- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from the + deployment/stateful-set container spec. Also remove the `tmp` `emptyDir` volume/mount + (`IPathProvider`'s temporary directory, per AGENTS.md) — but only if nothing else in the + container mounts `/tmp` for an unrelated reason; check first. +- Remove the `storage-storageclass.yaml`/`service-headless.yaml` apply blocks from the + `Kubernetes Deploy` workflow step. +- Remove the `STORAGE_SIZE`/`STORAGE_SHARE_NAME` workflow env vars, if nothing else uses them. + +## Kubernetes — Azure + +- Delete `.kubernetes/storage-pv.yaml` and `.kubernetes/storage-pvc.yaml`. +- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from + `.kubernetes/deployment.yaml`. Also remove the `tmp` `emptyDir` volume/mount, with the same + caveat as the Local section above — only if nothing else needs `/tmp`. +- Remove the `Storage Role Permissions` and `Create Fileshare` workflow steps, and the + `$env:VOLUME_NAME_SUFFIX = ...` derivation step, if nothing else in the workflow still uses + `%VOLUME_NAME_SUFFIX%`. +- Remove the `storage-pv.yaml`/`storage-pvc.yaml` apply block from `Kubernetes Deploy`. +- Remove the `STORAGE_SIZE`/`STORAGE_SHARE_NAME` workflow env vars. Only remove + `AZURE_GROUP_STORAGE`/`AZURE_GROUP_BACKUP` if nothing else in the workflow still references + them — Managed Identity or other Azure-backed providers may share them. + +## After making the change + +- Show the user every file touched/deleted, grouped by concern (app code, local docker-compose, + and for Azure, Staging/Production CI + K8s) — same reasoning as the add skill: too many files + for a flat list to be easy to sanity-check. +- Restate anything flagged in step 2 — a required `IPathProvider` injection that will now crash + the app — one more time here, even if the user already confirmed it. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, say so explicitly. diff --git a/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj b/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj new file mode 100644 index 00000000..d9e8e500 --- /dev/null +++ b/Api.Auth.External.Microsoft/.docker/docker-compose.dcproj @@ -0,0 +1,13 @@ + + + + 2.1 + Linux + false + http://localhost:{ServicePort}/docs + $(ProjectName) + + + + + \ 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 new file mode 100644 index 00000000..02b31ef3 --- /dev/null +++ b/Api.Auth.External.Microsoft/.docker/docker-compose.yml @@ -0,0 +1,17 @@ +services: + api.auth.external.microsoft: + image: api.auth.external.microsoft + hostname: api-auth-external-microsoft + restart: on-failure + ports: + - 8080:8080 + build: + context: ../Api.Auth.External.Microsoft + dockerfile: "Dockerfile.Local" + networks: + - network + +networks: + network: + name: network + driver: bridge diff --git a/Api.Auth.External.Microsoft/.dockerignore b/Api.Auth.External.Microsoft/.dockerignore new file mode 100644 index 00000000..e694ae21 --- /dev/null +++ b/Api.Auth.External.Microsoft/.dockerignore @@ -0,0 +1,12 @@ +.dockerignore +.env +.git +.gitignore +.vs +.vscode +docker-compose.yml +docker-compose.*.yml +*/bin +*/obj +!obj/Docker/publish/* +!obj/Docker/empty/ diff --git a/Api.Auth.External.Microsoft/.github/copilot-instructions.md b/Api.Auth.External.Microsoft/.github/copilot-instructions.md new file mode 100644 index 00000000..39722a8f --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/copilot-instructions.md @@ -0,0 +1,55 @@ +# Copilot instructions — Nano Framework + +This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the +**Nano framework** for API/Web/Console applications. Before making changes, check the repo root +for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +exact base classes, configuration, and gotchas for that specific solution. These instructions are +a short always-on summary; `AGENTS.md` takes precedence on any conflict. + +## Solution shape + +A Nano app named `{name}` conventionally looks like: + +- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, + `appsettings*.json`). +- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; + many smaller apps use a single-project layout with everything in `{name}/` instead. +- `.tests/Tests.{name}/` — test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local + orchestration, deployment, and CI/CD. + +Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework +requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +by location. + +## Conventions to follow + +- **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project + already consistently uses another `TIdentity` everywhere (entities, mappings, repository, + controllers, API client) — it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — + omitting it silently breaks inherited behavior (soft delete, audit, etc.). +- **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + derives the route from the class name. +- No manual registration is needed for mappings, controllers, startup tasks, or API clients — + Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless + a project's existing code clearly does otherwise. +- Match existing conventions in the project (nullable-reference style, split vs single-project + layout, which base classes are used) rather than introducing a new style for one change. +- Don't silently expand scope: adding an entity means the model/mapping/criteria/controller for + that entity, not also touching `Program.cs`, adding NuGet packages, or running EF migrations, + unless asked. + +## Prompts + +`.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) +endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. 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 new file mode 100644 index 00000000..ec64ef04 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,180 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# 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 +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`) +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 +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 +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 +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 +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. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + 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 + 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. +3. **Is a client with this class name already 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. +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 + 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 +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `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, + 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 + 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 + 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 + 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 - + 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 + 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 + 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 - + 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; + 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 +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +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 + +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 +"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 +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, + 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, + 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 + dependency. +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. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-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. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` 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 + 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` + 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 + 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 + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client.prompt.md new file mode 100644 index 00000000..d1ac8728 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-api-client.prompt.md @@ -0,0 +1,69 @@ +--- +mode: agent +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. +--- + +# Nano add API client + +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. + +## Before making any change, determine + +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. + +## Client class + +`{ThisApp}.Models/Api/{ClientName}.cs`: + +```csharp +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit +public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); +``` +```csharp +// Identity-backed - adds the .Identity method group for every consumer +public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); +``` + +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. + +## After making the change + +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-apikey.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-apikey.prompt.md new file mode 100644 index 00000000..fd91f79d --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -0,0 +1,113 @@ +--- +mode: agent +description: Configure Nano's built-in API-key authentication (Data:Identity:ApiKey) on a Nano.Library-based API/Web application that already has Identity registered - works standalone, with no JWT/App:Authentication involved, or layered on top of an existing nano-add-authentication-jwt setup. Use when the user asks to add API-key authentication, an X-Api-Key header scheme, or machine-to-machine auth to a Nano API or Web application. +--- + +# Nano add API-key authentication + +Configures Nano's built-in API-key authentication on an existing Nano API/Web application. Read +`AGENTS.md`'s `#### Identity` and `#### Authentication` sections first - `ApiKey.Secret` lives +under `Data:Identity`, but its actual authentication behavior is documented in `Authentication`. + +**API-key auth does not require JWT.** This is the key thing that distinguishes it from +`nano-add-authentication-jwt`, and the reason it's a separate skill: per Nano's own +`AddNanoAuthentication` registration logic, the default scheme is chosen from +`(Jwt configured, ApiKeyOptions configured)` - `(false, true)` selects API-key-only. In that mode +there is **no `AuthController`** (it requires `IAuthRepository`, which is only registered when +`Jwt != null` - adding the controller without `Jwt` would fail DI resolution) and **no login +endpoint** - `ApiKeyAuthenticationHandler` validates the `X-Api-Key` header directly against the +identity store on every single request, with no token step at all. + +## Before making any change, determine + +1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and + `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an + identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + If so, say so and stop. +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` + for `App:Authentication:Jwt`, or an existing `AuthController`. + - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no + `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a + controller "just in case" - it would crash DI (see above). + - **Already configured** (`nano-add-authentication-jwt` already ran) - this app moves from + JWT-only to `JWT_OR_APIKEY` **automatically, from config alone**, nothing to change in the + existing `AuthController`. Its already-existing `LogInApiKeyAsync` action (visibility gated + purely on `Data:Identity:ApiKey:Secret` being set, per `ConditionalActionsConvention`) + becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the + user this new endpoint just appeared, it's a real behavior change on an app that may already + have callers, not just an implementation detail. +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case + the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific + for this skill to gate on beyond that. + +## appsettings.json + +Base `appsettings.json`: add `Data:Identity:ApiKey:Secret: null` (sibling of the rest of +`Identity` - see `nano-add-identity`). No local Development value needed by default - API keys +are normally created per-user via the identity-management endpoints +(`{id}/api-keys/create`, per AGENTS.md's `#### Identity user controller` table) rather than +hardcoded, unlike the shared JWT Development key pair. If the user wants a fixed key for local +testing convenience, set one in `appsettings.Development.json` instead of the base file. + +## Kubernetes / GitHub Actions (Staging/Production) + +1. **Workflow env var**: + ```yaml + AUTH_API_KEY_SECRET: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_API_KEY_SECRET || secrets.STAGING_AUTH_API_KEY_SECRET }} + ``` +2. **`.kubernetes/auth-api-key-secret.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: auth-api-key-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + apikey-secret: %AUTH_API_KEY_SECRET% + ``` + Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | + kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. + Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. +3. **`.kubernetes/deployment.yaml`** env entry: + ```yaml + - name: Data__Identity__ApiKey__Secret + valueFrom: + secretKeyRef: + name: auth-api-key-secret + key: apikey-secret + ``` + The env var name has a trailing `__Secret` - verify against an existing `deployment.yaml` in + the project if one has this wired already; a stale Lessons README once dropped that suffix, + so don't copy it from documentation without cross-checking a real manifest. + +## After making the change + +- Show the user every file touched. +- State plainly which mode this app ended up in - pure API-key (no controller, no login step) or + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this + implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. +- If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole + response. 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 new file mode 100644 index 00000000..7ae3707e --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -0,0 +1,395 @@ +--- +mode: agent +description: Configure Nano's built-in JWT authentication (App:Authentication:Jwt) on a Nano.Library-based API/Web application - adds the Jwt configuration, the AuthController, the Development key setup, and (for the token-issuing app) the Staging/Production key-generation and Kubernetes secret. Use when the user asks to add login, sign-in, or JWT authentication to a Nano API or Web application - not for adding a user store by itself (that's nano-add-identity) or for API-key authentication by itself (that's nano-add-authentication-apikey, which works standalone without any of this). +--- + +# Nano add JWT authentication + +Configures Nano's built-in JWT authentication on an existing Nano API/Web application. Read +`AGENTS.md`'s `#### Authentication` section first - it documents the full `Configuration` table, +the `AuthController`'s sub-repository table, and the persistent-vs-transient distinction in +detail; this skill does not repeat that, only how to apply it. + +**This skill is JWT-specific.** API-key authentication is a genuinely independent auth mode in +Nano - it does not require `Jwt` at all, has no `AuthController`, and is `nano-add-authentication-apikey`'s +job, not this one. See step 6 below for what happens when both are configured on the same app. + +**No `Program.cs` registration call.** Unlike every other add-provider skill, Authentication is +pure config plus one controller - `IAuthRepository`'s sub-repositories self-populate based on +whichever config sections exist (`Jwt.RootLogin` → root login, [Identity](nano-add-identity) → +persistent login, `Jwt.ExternalLogins` with no Identity → transient login). There's nothing to +add to `.ConfigureServices(...)`. + +**Two request shapes.** A request naming this skill is one of: +1. **Persistent auth** - `Jwt` config + `AuthController` on top of already-configured Identity. +2. **Transient auth** - `Jwt` config + `AuthController`, but with `Jwt.ExternalLogins` instead of + Identity. +Figure out which one applies before touching anything - steps 1–5 below are how. Either can be +layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see +step 6. + +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + +## Before making any change, determine + +1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. + - **Issuer**: needs both `PublicKey` and `PrivateKey` in Staging/Production, and creates the + `auth-jwt-secret` Kubernetes secret from real GitHub secrets. + - **Validator-only**: needs only `PublicKey` in Staging/Production, and must **not** create or + re-apply the secret - it references the one the issuer app already created. See the + Kubernetes section below; getting this backwards silently corrupts the shared secret with + unexpanded placeholder values - a real bug found and fixed this way in this codebase, so + don't repeat it. + - This distinction **does not apply to Development** - see below. +2. **Persistent or transient auth?** Check whether [Identity](nano-add-identity) (`Data:Identity`) + is already configured. + - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs + `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. + - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. + - If the user wants persistent auth but Identity isn't registered yet, stop and point them at + `nano-add-identity` first. +3. **Is Authentication already configured?** Check the base `appsettings.json` for + `App:Authentication:Jwt`, or an existing `AuthController`. If present, say so before changing + anything. +4. **Application type.** The `AuthController` is API/Web only - a Console app has no HTTP surface + to expose it on. A Console app can still have `Jwt` configured, but only for its own outbound + Api Client authentication (`Apis:{Client}:LogInRoot`, a different, already-documented AGENTS.md + concern) - not something this skill scaffolds a controller for. +5. **Is this a Console app whose only use of `Jwt` is outbound Api Client auth?** Skip the + controller step below in that case, per step 4. +6. **Is API-key authentication already configured** (`Data:Identity:ApiKey:Secret` set)? Check + the base `appsettings.json`. If so, this app was previously in pure API-key-only mode - no + `Jwt`, no `AuthController` (see `nano-add-authentication-apikey`: that controller would fail to resolve + `IAuthRepository` without `Jwt` configured, so it genuinely didn't exist yet). Adding `Jwt` now + changes two things **automatically, from config alone** - nothing extra to build, but tell the + user about both: + - Nano's scheme selection (`AddNanoAuthentication`, based on whether `Jwt`/`ApiKeyOptions` are + each present) switches from API-key-only to `JWT_OR_APIKEY` - existing API-key callers keep + working unchanged, requests carrying a JWT `Authorization` header now also work. + - The `AuthController` this skill adds will immediately expose `/auth/login/apikey` (its + visibility is gated purely on `Data:Identity:ApiKey:Secret` being set, per + `ConditionalActionsConvention`) - callers can now trade an API key for a JWT once instead of + presenting the key on every request. + +## appsettings.json - Jwt + +Base `appsettings.json` (sibling of `App:Version`/`App:Hosting`, per AGENTS.md's `##### Configuration` +example): `Issuer`/`Audience`/`PublicKey`/`PrivateKey` all `null`, `Expiration`/`RefreshExpiration` +at their framework defaults (`01:00:00`/`72:00:00`). Leave `RootLogin`/`ExternalLogins` out +entirely unless configuring them now - they're opt-in additions, not blank placeholders. + +**`appsettings.Development.json` - use the existing shared key pair, don't generate a new one.** +Every app in this codebase (issuers and validators alike) uses the exact same hardcoded RSA key +pair locally: + +```json +"App": { + "Authentication": { + "Jwt": { + "Issuer": "nano.development", + "Audience": "nano.development", + "PublicKey": "MIIBCgKCAQEAv7iVNUS5wT7Fvg/hkmlvvPnOW7Rcyh7dFStJSTtM+7f74+GGVJLl6spXasnsQ7v6rw7vlyb+uVk1UaQsUA38luSNGWfPqc3JAtkeJPWCu1kN79Yo3im7Qx6B1u4gf0AR3n86ClQGz3O5Jxo8M3+zlwveYnlf6bqhBakOVdPS5tX0Bvh/F9lXiEF53EZEcfuHjBjDLik9PUdTjqehPLCPyI1/FbfE8P1Y4S7AEfs2fIqXGxJNXDyoDRvi42qefqXcsmzBUDYtHqvwSHWcDn5DXDRY2FYkyESMvd7RRGwI6U0g8V9k3Qudd4LjQTs8LdBu5u25wvqx37Y1518BPqGQkQIDAQAB", + "PrivateKey": "MIIEowIBAAKCAQEAv7iVNUS5wT7Fvg/hkmlvvPnOW7Rcyh7dFStJSTtM+7f74+GGVJLl6spXasnsQ7v6rw7vlyb+uVk1UaQsUA38luSNGWfPqc3JAtkeJPWCu1kN79Yo3im7Qx6B1u4gf0AR3n86ClQGz3O5Jxo8M3+zlwveYnlf6bqhBakOVdPS5tX0Bvh/F9lXiEF53EZEcfuHjBjDLik9PUdTjqehPLCPyI1/FbfE8P1Y4S7AEfs2fIqXGxJNXDyoDRvi42qefqXcsmzBUDYtHqvwSHWcDn5DXDRY2FYkyESMvd7RRGwI6U0g8V9k3Qudd4LjQTs8LdBu5u25wvqx37Y1518BPqGQkQIDAQABAoIBAEwNH3sS+RCUIwLC7/sRQhbXjSlJgalX1uFH23lmQaJ0mEIMOyofX37kpwqgcM1pqwZ4SUhPWqoRnhn1ovJaqgD9Ro92Y6T7EarEj7Wfgi1pJSMnc+y05yi32E93BIMV2kDFfTONo2n1gNPnD0xqcsYPGjc76HUh6DADoMEhFr8kHaz4J2daKV0tJjApNt2oabk8BLQEq9Uv22DsLfL+nEOHPhSMk7EmNv3QQgUNH5ugeDNfTNr+A6K8YMbVVrmDalZS/GBWSscnJ9Ma2WrHJ/x2IRQECVMf6U05vrgtKb9imPcN09ccItIzcK/8ZBbSw2v+Gzf1Je447SYT9njAOiUCgYEAzOAsty4cxCLSWt2GBTE58MoThNeiVRBvc6Gw5B1olCCnWkVxRDYwYlPnwvemqa+YsfijrjVkuS0kJmfrGJ/MkV8Wsx2XL6mRyCBXOUog0U/Nh20ANU8kcmEMkGVtxDUM8hr9QQ5qex/LmSiy8YG4c4mfD6s7KvWnRxJcviXmgUMCgYEA75AQssujQtycWx6fZ/aBQLc6+xSlGaY73k2R8XLwMSASAeq1erxCSsuPF5lPRnQ4VZyfSOV9AcOyLgeJCi4ePJEnfZZMcGkKNt2yMsZoWUlJSmHIXhEEfKqu8Qo0TRu4/vQYPKwTVXdpbZJIlDgzztPdC1gOpCg3QQH16wPyL5sCgYAQ5Ygqj14F+w04Oz7bXMT3i+LyOMqFk3Ztpe8t0RMX7F2A/2spAgMZiOv7U2tmYToJq4TsUDD/aK6rkDR+cmdvsdTwbsdSQfzo8WngKrHsMVW1DpNO0jkiSci8e/EClpF7wigS3np/rw6ekhG4A0fQF5CLvUaC84GZRfVqJTwOewKBgQC4oTKNae54oGgMvewjBtOU2eKmEcIwo3JuoSACkw/U/J+ERKz7W85HsNymVmzHotir+pq0ZtHSI03Wtc4DP4nkKgbifoyI8huCL5igE1PmxFms7vGqtbjcj/tmH/QxHVWVgPCRChmYfACQBvbS7QHYvGYW0RXvpGL5QhaSuybTUwKBgG/p/gsj6yUDAiNhEWpSsMWl/3xJeIobcnH1XQrrXWIzL1xZtX1EkcqLM6++Ojjre3UKj96ZDFRpJH4uxTilE9MDOOf+PLoL01rr1rmzaWDr5NsI3nqz2AS6ZSuofO0rs7nQlKtTnQY0vlzPGqfQp4uQ11KPzO2PB9TEGwnZy5HV", + "Expiration": "24:00:00" + } + } +} +``` + +- **`PrivateKey` goes here even on a validator-only app**, if the app also configures + `Jwt.RootLogin` for isolated local testing (the common case for an internal service - AGENTS.md: + "useful in Development when testing a service in isolation"). Root login self-issues a JWT, + which needs a private key regardless of the app's Staging/Production role. Only omit + `PrivateKey` in Development for an app that genuinely never self-issues locally (e.g. a + pure Public API with no isolated-testing story of its own). +- `Expiration: "24:00:00"` (vs. the base file's `01:00:00`) is the established convention for + Development - longer-lived tokens are less annoying to work with locally. Not required, but + match it unless the user asks otherwise. +- Add a `RootLogin` block (`Username`/`Password`) alongside `Jwt` in Development if this app + should support isolated local testing - ask for credentials, or use a placeholder like + `admin@domain.com` / a throwaway password if the user doesn't care. + +**`appsettings.Staging.json` / `appsettings.Production.json`** - only `Issuer`/`Audience` +overrides, no keys (those come from the Kubernetes secret, never a static file): + +```json +"App": { "Authentication": { "Jwt": { "Issuer": "nano.staging", "Audience": "nano.staging" } } } +``` +```json +"App": { "Authentication": { "Jwt": { "Issuer": "nano.production", "Audience": "nano.production" } } } +``` + +## AuthController (API/Web only) + +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **at login, don't add this controller 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. +- 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 + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + 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. +- **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` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. + +`Controllers/AuthController.cs`, main app project: + +```csharp +public class AuthController(ILogger logger, IAuthRepository authRepository) + : BaseAuthController(logger, authRepository); +``` + +Nothing to implement - every endpoint the current config enables (per AGENTS.md's sub-repository +table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and +`IAuthRepository` to match (same rule as every other controller in this ecosystem). + +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. If the request + names Microsoft specifically, use that skill instead of configuring `Jwt.ExternalLogins.Microsoft` + by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + 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. + +## Kubernetes / GitHub Actions (Staging/Production) - issuer app only + +Only the app that **issues** tokens does this. A validator-only app does **not** create or +re-apply this secret - it only references the `auth-jwt-secret` the issuer already created (see +its `deployment.yaml` entry below). Re-applying it from an app with no real key values set pushes +unexpanded placeholder text into the shared secret, silently corrupting the real one - don't do +it for any app but the issuer. + +1. **Workflow env vars**: + ```yaml + AUTH_JWT_PUBLIC_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PUBLIC_KEY || secrets.STAGING_AUTH_JWT_PUBLIC_KEY }} + AUTH_JWT_PRIVATE_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PRIVATE_KEY || secrets.STAGING_AUTH_JWT_PRIVATE_KEY }} + ``` +2. **`.kubernetes/auth-jwt-secret.yaml`** (new file, issuer app only): + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: auth-jwt-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + jwt-public-key: %AUTH_JWT_PUBLIC_KEY% + jwt-private-key: %AUTH_JWT_PRIVATE_KEY% + ``` + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +## Kubernetes - deployment.yaml + +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: + +Issuer app (both keys): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +- name: App__Authentication__Jwt__PrivateKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-private-key +``` + +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` + +## API-key authentication + +Not this skill's job - see `nano-add-authentication-apikey`, which works whether or not `Jwt` is +configured on this app. If the user asked for both in one request, run both skills; step 6 above +covers the one thing each needs to know about the other. + +## Generating real keys (Staging/Production, or a deliberate Development change) + +Never hardcode Staging/Production keys - generate a unique pair and store both halves as +GitHub secrets (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), consumed only via the +Kubernetes secret above. Generate with a throwaway Console app (from AGENTS.md / +`Nano.App.Api/README.md`'s `## Authentication` section): + +```csharp +using System.Security.Cryptography; + +using var rsa = RSA.Create(); + +var publicKey = rsa + .ExportRSAPublicKeyPem() + .Replace("-----BEGIN RSA PUBLIC KEY-----", "") + .Replace("-----END RSA PUBLIC KEY-----", "") + .Replace("\n", string.Empty); + +var privateKey = rsa + .ExportRSAPrivateKeyPem() + .Replace("-----BEGIN RSA PRIVATE KEY-----", "") + .Replace("-----END RSA PRIVATE KEY-----", "") + .Replace("\n", string.Empty); + +Console.WriteLine("PUBLIC KEY:"); +Console.WriteLine(publicKey); +Console.WriteLine(); +Console.WriteLine("PRIVATE KEY:"); +Console.WriteLine(privateKey); + +Console.Read(); +``` + +## After making the change + +- Show the user every file touched, grouped by concern (appsettings per environment, the + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. +- Point them at the snippet above for generating real Staging/Production keys - never the + hardcoded Development pair. +- If they want to change the Development key pair from the shared default, warn explicitly: it + must change **identically across every app** in the solution, or apps stop being able to + validate each other's locally-issued tokens. +- If step 2 stopped the skill early for a missing Identity prerequisite, that's the whole + response - don't partially wire persistent auth while waiting on it. +- If step 6 applied (API-key was already configured), restate the automatic scheme-switch and + the newly-visible `/auth/login/apikey` endpoint one more time - it's a real behavior change on + an app that already had callers, worth a second confirmation, not just a note in passing. 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 new file mode 100644 index 00000000..92a12e93 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,291 @@ +--- +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). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**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 +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + 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 + `--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`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that + starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email", "offline_access" ] + } +} +``` + +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +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. + +`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. + +## 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: + +```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`). + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + 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_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 $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` + --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_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app 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. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AUTH_MICROSOFT_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-availability-check.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-availability-check.prompt.md new file mode 100644 index 00000000..f1f4c26a --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-availability-check.prompt.md @@ -0,0 +1,142 @@ +--- +mode: agent +description: Add continuous uptime monitoring for a publicly-exposed Nano API or Web application - creates an Azure Application Insights availability (ping) test against the app's /healthz endpoint across every DNS zone, plus a metric alert rule. Requires the app already be publicly exposed. Use when the user asks to add availability monitoring, an uptime check, or a ping test to a Nano application. +--- + +# Nano add availability check + +Adds continuous availability monitoring for an existing, publicly-exposed Nano API or Web +application, via an Azure Application Insights ping test against `/healthz` plus a metric alert. +This is CI/infrastructure only - nothing in the application itself changes. + +## Before making any change, determine + +1. **Is the app publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` + (`nano-add-public-exposure`). **Required** - the ping test hits a real public HTTPS URL; there + is nothing for it to test otherwise. If not present, stop and point the user at + `nano-add-public-exposure` first - don't wire a check against a URL that doesn't resolve. +2. **Is Health Checks enabled?** The test targets `/healthz` specifically, matching on the + `"status":"unhealthy"` string to detect failure - check for `App:HealthCheck` + (`nano-add-health-checks`). If absent, `/healthz` doesn't exist at all; stop and point the + user at that skill too. +3. **Is Availability Check already configured?** Check the workflow for an "Add Availability + Check" step. If present, say so and stop. +4. **`SUB_DOMAIN_NAME` must already be set** (from `nano-add-public-exposure`) - this step reuses + it, doesn't define it. Confirm it's there rather than assuming. + +## GitHub Actions + +1. **Workflow env var**: + ```yaml + AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }} + ``` +2. **"Add Availability Check" step**, placed at the end of the pipeline (after deployment). + Idempotent - creates the web test and alert only if they don't already exist for each DNS + zone, safe to run every deploy: + ```yaml + - name: Add Availability Check + shell: pwsh + run: | + $env:AZURE_LOCATION = az monitor log-analytics workspace list -g $env:AZURE_GROUP_LOGS --query [0].location -o tsv; + $env:APPLICATION_INSIGHT_ID = az monitor app-insights component show -g $env:AZURE_GROUP_LOGS --query [0].id -o tsv; + $env:HIDDEN_LINK = 'hidden-link:' + $env:APPLICATION_INSIGHT_ID + '=Resource'; + + $zoneNames = az network dns zone list -g $env:AZURE_GROUP_DNS --query "[].name" -o json | ConvertFrom-Json + + foreach ($zoneName in $zoneNames) + { + $env:WEB_TEST_NAME = $env:SERVICE_NAME + '-availability-' + $env:ASPNETCORE_ENVIRONMENT.ToLower() + '-' + $env:SUB_DOMAIN_NAME + '-' + ($zoneName.TrimEnd('.') -replace '\.', '-') + + az monitor app-insights web-test show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_NAME --query id -o tsv 2>$null + + if ($LastExitCode -ne 0) + { + az monitor app-insights web-test create ` + -n $env:WEB_TEST_NAME ` + --defined-web-test-name $env:WEB_TEST_NAME ` + -g $env:AZURE_GROUP_LOGS ` + -l $env:AZURE_LOCATION ` + --kind ping ` + --web-test-kind standard ` + --frequency 300 ` + --enabled true ` + --retry-enabled true ` + --ssl-check true ` + --ssl-lifetime-check 30 ` + --http-verb GET ` + --request-url https://$env:SUB_DOMAIN_NAME.$zoneName/healthz ` + --expected-status-code 200 ` + --content-validation content-match='"status":"unhealthy"' ignore-case=true pass-if-text-found=false ` + --tags $env:HIDDEN_LINK ` + --locations Id='us-ca-sjc-azr' ` + --locations Id='us-va-ash-azr' ` + --locations Id='emea-gb-db3-azr' ` + --locations Id='emea-nl-ams-azr' ` + --locations Id='apac-hk-hkn-azr'; + + if ($LastExitCode -ne 0) + { + throw "error"; + } + } + + $env:WEB_TEST_ALERT_NAME = $env:WEB_TEST_NAME + "-alert"; + + az resource show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_ALERT_NAME --query id -o tsv 2>$null; + + if ($LastExitCode -ne 0) + { + $env:WEB_TEST_ID = az monitor app-insights web-test show -g $env:AZURE_GROUP_LOGS -n $env:WEB_TEST_NAME --query id -o tsv; + $env:ACTION_GROUP_ID = az monitor action-group list -g $env:AZURE_GROUP_LOGS --query [0].id -o tsv; + + $alertRuleProperties = @{ + severity = 1 + enabled = $true + scopes = @($env:WEB_TEST_ID, $env:APPLICATION_INSIGHT_ID) + evaluationFrequency = "PT1M" + windowSize = "PT5M" + criteria = @{ + "odata.type" = "Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria" + webTestId = $env:WEB_TEST_ID + componentId = $env:APPLICATION_INSIGHT_ID + failedLocationCount = 2 + } + actions = @( + @{ actionGroupId = $env:ACTION_GROUP_ID } + ) + } + + $json = $alertRuleProperties | ConvertTo-Json -Depth 10 + [System.IO.File]::WriteAllText("$PWD/alert.json", $json, [System.Text.UTF8Encoding]::new($false)) + + az resource create ` + -g $env:AZURE_GROUP_LOGS ` + -n $env:WEB_TEST_ALERT_NAME ` + -l global ` + --resource-type "Microsoft.Insights/metricAlerts" ` + -p '@alert.json'; + + if ($LastExitCode -ne 0) + { + throw "error"; + } + } + } + ``` + +This creates one ping test **per DNS zone** the app is reachable under (matching +`nano-add-public-exposure`'s multi-zone hostname derivation), each pinged from 5 global Azure +locations every 5 minutes, alerting when at least 2 locations report failure within a 5-minute +window. `az monitor log-analytics workspace list`/`az monitor app-insights component show`/ +`az monitor action-group list` all assume a Log Analytics workspace, Application Insights +component, and action group already exist in `AZURE_GROUP_LOGS` - one-time, cluster/subscription- +level prerequisites outside this skill's scope; tell the user if any of those would come back +empty rather than assuming they're provisioned. + +## After making the change + +- Show the user the workflow changes. +- If step 1 or step 2 stopped the skill early, that's the whole response - don't wire a check + against a URL or endpoint that doesn't exist yet. +- Mention that the actual web test/alert resources are created on the **next deploy run**, not + by editing the workflow file alone. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-azure-managed-identity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-azure-managed-identity.prompt.md new file mode 100644 index 00000000..75c19e68 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -0,0 +1,113 @@ +--- +mode: agent +description: Wire Azure Managed Identity federated to Kubernetes Workload Identity onto a Nano.Library-based application's Kubernetes deployment - adds service-account.yaml, the workload-identity pod annotations, and the CI "Managed Identity" step that provisions and federates the identity. Use when the user asks to add Managed Identity, Workload Identity, or passwordless Azure resource access to a Nano API, Web, or Console application - typically a prerequisite before setting a Data or Storage provider to AuthenticationType: Azure. +--- + +# Nano add managed identity + +Wires Azure Managed Identity, federated to Kubernetes Workload Identity, onto an existing Nano +API, Web, or Console application's Kubernetes deployment. This is infrastructure-only - there's +no `App:`/`Data:` config section it owns itself; providers consume the identity it establishes +through their own `AuthenticationType: Azure` setting. `nano-add-data-provider` (MySql/ +PostgreSQL/SqlServer) and `nano-add-storage-provider` (Azure section) both already assume this is +wired before their Staging/Production sections apply - this skill is what makes that true. + +## Before making any change, determine + +1. **Is Managed Identity already wired?** Check for `.kubernetes/service-account.yaml` and a + `Managed Identity` step in the `Kubernetes Deploy` workflow. If present, say so and stop. +2. **What's it for?** Ask if not already given - usually about to back a Data provider + (`AuthenticationType: Azure`) or Azure Storage. This skill only establishes the identity + itself; flipping a provider's `AuthenticationType` is that provider's own skill's job, not + this one's - don't do it here even if the reason is already known. +3. **Application type.** No difference in wiring between API, Web, or Console - whichever of + `deployment.yaml`/`cronjob.yaml` the app has gets the same annotations. +4. **Does the workflow already have `AZURE_GROUP_KUBERNETES`?** Every app's workflow needs it + already for basic AKS deploy (`az aks get-credentials`), so it's virtually always already + present - confirm rather than assume, but there's normally nothing to add for it here. + +## Kubernetes + +1. **`.kubernetes/service-account.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: %SERVICE_NAME%-service-account + namespace: %KUBERNETES_NAMESPACE% + annotations: + azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% + ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. +2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod + template's metadata and reference the service account in the pod spec: + ```yaml + template: + metadata: + labels: + azure.workload.identity/use: "true" + spec: + serviceAccountName: %SERVICE_NAME%-service-account + ``` + +## GitHub Actions + +Add the `Managed Identity` step, placed after the AKS-credentials step (`az aks get-credentials`) +and before `Kubernetes Deploy`. It's idempotent - creates the identity only if it doesn't already +exist, and (re)creates the federated credential every run regardless: + +```yaml +- name: Managed Identity + shell: pwsh + run: | + $env:IDENTITY_NAME = $env:SERVICE_NAME + "-identity"; + $env:IDENTITY_PRINCIPAL_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query principalId -o tsv; + $env:KUBERNETES_ISSUER_URL = az aks list -g $env:AZURE_GROUP_KUBERNETES --query [0].['oidcIssuerProfile.issuerUrl'] -o tsv; + + if (-not $env:IDENTITY_PRINCIPAL_ID) + { + az identity create ` + -g $env:AZURE_GROUP_KUBERNETES ` + -n $env:IDENTITY_NAME; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + $env:IDENTITY_PRINCIPAL_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query principalId -o tsv; + } + + $env:IDENTITY_CLIENT_ID = az identity show -g $env:AZURE_GROUP_KUBERNETES -n $env:IDENTITY_NAME --query clientId -o tsv; + + az identity federated-credential create ` + --name $env:SERVICE_NAME-credentials ` + --resource-group $env:AZURE_GROUP_KUBERNETES ` + --identity-name $env:IDENTITY_NAME ` + --issuer $env:KUBERNETES_ISSUER_URL ` + --subject "system:serviceaccount:${env:KUBERNETES_NAMESPACE}:${env:SERVICE_NAME}-service-account" ` + --audience api://AzureADTokenExchange; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + echo "IDENTITY_NAME=$env:IDENTITY_NAME" >> $env:GITHUB_ENV; + echo "IDENTITY_CLIENT_ID=$env:IDENTITY_CLIENT_ID" >> $env:GITHUB_ENV; + echo "IDENTITY_PRINCIPAL_ID=$env:IDENTITY_PRINCIPAL_ID" >> $env:GITHUB_ENV; +``` + +Apply `service-account.yaml` in the `Kubernetes Deploy` step, before `deployment.yaml`/ +`cronjob.yaml` - same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every +other manifest. + +## After making the change + +- Show the user every file touched. +- Remind them this only establishes the identity - nothing consumes it yet. Point them at + `nano-add-data-provider` (set `AuthenticationType: Azure`) or `nano-add-storage-provider` (its + Azure section) as the actual next step for whatever prompted this. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-console-worker.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-console-worker.prompt.md new file mode 100644 index 00000000..873c0183 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-console-worker.prompt.md @@ -0,0 +1,60 @@ +--- +mode: agent +description: Add a Console Worker to a Nano Console application - a class deriving BaseWorker that runs a Console app's actual run-to-completion job. Use when the user asks to add a worker, background job, or the main task to a Nano Console application. +--- + +# Nano add console worker + +Adds a Console Worker to an existing Nano Console application. Read AGENTS.md's +`### Console Workers` section first - it documents the lifecycle and error-handling semantics in +full; this skill is just the file shape. + +## Before making any change, determine + +1. **Application type.** Console Workers are a `NanoConsoleApplication`-specific concept - check + `Program.cs`. If the app is API/Web, this isn't the right skill (background work there is a + [Startup Task](nano-add-startup-task) instead, which has different lifecycle semantics - + confirm which the user actually wants). +2. **Name and job.** Ask if not already given - what the worker actually does. +3. **Does it need to signal failure?** Per AGENTS.md's ⚠: unlike a startup task, a worker that + throws does **not** abort anything - the exception is caught and logged, the worker is treated + as complete, and every sibling worker still runs. If this worker's failure should actually + surface (e.g. a non-zero exit code for a CronJob to alert on), that has to be handled inside + `OnStartAsync` itself - ask whether that matters for this job before treating a plain override + as sufficient. + +## Worker class + +`Workers/{Name}Worker.cs` in the application project (conventional location, not enforced - +discovered by type): + +```csharp +public class MyWorker(ILogger logger) : BaseWorker(logger) +{ + public override async Task OnStartAsync(CancellationToken cancellationToken = default) + { + // the actual work + } + + // optional - only override if cleanup is needed; runs after every worker's OnStartAsync + // finishes (concurrently with sibling workers' OnStopAsync), right before the app exits + public override Task OnStopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} +``` + +No registration needed - every non-abstract `IWorker` in the entry assembly is discovered and +registered `Scoped` automatically, same mechanism as Startup Tasks. Any other registered service +can be injected into the constructor alongside `logger`. + +Remember the lifecycle this fits into: all Startup Tasks finish first, then every worker's +`OnStartAsync` runs concurrently with its siblings, then every `OnStopAsync` runs concurrently, +then the process exits on its own (`IHostApplicationLifetime.StopApplication()`) - this is what +makes a Console app a run-to-completion job rather than a long-running daemon. + +## After making the change + +- Show the user the file added. +- If step 3 identified a real failure-signaling need, make sure `OnStartAsync` actually handles + it (e.g. `Environment.ExitCode = 1` before returning, or rethrowing after logging if that's the + intended signal) - don't leave a silently-swallowed failure in a job the user said needs to + alert on error. 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 new file mode 100644 index 00000000..65048c08 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -0,0 +1,570 @@ +--- +mode: agent +description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. +--- + +# Nano add custom endpoint + +Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read +AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and +`### Api Clients` sections first; this skill does not repeat those, only how to combine them +following this solution's own established conventions (one-liner XML doc summaries, +`[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the +controller's own namespace). + +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. + +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. + +--- + +## Step 1 - Confirm a custom endpoint is actually needed + +Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed +insufficient - not just "less convenient." Walk through this before scaffolding anything: + +- **Can the desired response be expressed as the target entity plus some of its navigation + properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the + needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have + the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. +- **Two real limits of that mechanism, either of which can still justify going custom even when the + shape looks nav-expressible:** + - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but + never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; + a custom endpoint can. + - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes + it eager-loadable for *every* consumer of that entity's generic endpoints - other internal + services, other Public APIs - not just the one that prompted the change. If a navigation + genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size + concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped + custom endpoint instead of tagging it. +- **Still custom regardless of `[Include]`:** computed/aggregated fields that don't exist on the + entity, responses composed from more than one unrelated entity graph, or actual business logic + beyond read/write. A representative case: an action that has to validate something (e.g. an + email domain against a set of allowed domains) and then perform a multi-entity write as one + atomic operation, where the write can't happen at all until the validation passes - neither step + is expressible as a single generic `.Entity` call, and splitting them into two separate generic + calls from the caller would let the write happen without the validation ever running. This is + the right call for a custom endpoint, not a sign to keep looking for a generic-composition way + around it. +- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + promoting it. +- **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. +- **Is the actual need "the generic write plus an invariant that must always hold," not a new + route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect + after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a + sibling entity's existence makes the parent immutable) - and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it - that's a + case for **overriding the generic CRUD action** on the owning entity's own controller, not adding + a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action + instead of a new endpoint** below before scaffolding a new route for this. +- **Before designing a custom action (or a composition) around a delete or an update, check what + the database relationship already does for you.** A required (non-optional) EF Core relationship + with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + removes the dependent row(s) at the database level, so an explicit second delete call for that + child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable + (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL + `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse + gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto + the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child + collection still needs its own explicit add/remove calls (composed at the Public API, or inside + an overridden action - see below). Check both directions before adding calls a real cascade + already makes unnecessary, or assuming a collection reassignment does something it doesn't. + +If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers +this, say so and point the user at that instead of scaffolding something redundant - don't build a +custom action just because it was asked for without checking first. Note that adding a new +`[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic +surface, not a free side-effect - say so rather than tagging it silently. + +## Step 2 - Public API or internal service? + +Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: + +- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action + composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an + existing custom client method) into one response; it has no `IRepository` of its own. Go to + **Public API path** below. +- **Internal service controller** - the action implements logic directly against this app's own + `IRepository`/`IEventing`, either as a custom method on an existing entity controller + (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing + it at all. Go to **Internal service path** below. + +## Step 3 - Pin down shape and conventions + +- **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask + for whatever isn't already given - don't invent fields, routes, or status codes that weren't + asked for or that don't match an existing sibling action's pattern in the same + controller/project. +- **Naming and location conventions.** Skim an existing custom action in the same controller (or a + sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, + doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + established shape for this, don't invent a new one. + +--- + +## Shared DTO conventions + +Both paths below build request/response DTOs the same way - read this once, apply it wherever a +DTO comes up in either path: + +- **Only include properties the endpoint actually needs** - no speculative fields, and (for a + request) only what the *caller* should be able to set, never fields that represent + internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even + if the controller action happens to build an entity from the request afterward. +- **Match validation attributes to what the underlying entity/write actually needs, not just + `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on + a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + bad request is rejected by model binding before it ever reaches an Api Client call or a repository + write, instead of surfacing as a downstream 400/500. +- **Check for an existing sibling DTO with the same shape before defining a new one.** A response + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. +- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to + stitch together whatever the response needs before reaching for a custom Response DTO.** A custom + endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. +- **If the response leans on nested navigations, every level of that chain needs `[Include]`, not + just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in + the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the + property itself - and this applies independently at every level of the graph. A response built by + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response + constructor/mapping actually walks and confirm every property on it is tagged before assuming + `[Include]` "already covers this." + +--- + +## Public API path + +The controller composes calls that already exist elsewhere - this path never defines a new Api +Client method of its own. + +### Does the backing call already exist? + +Check whether the Api Client(s) this action needs are already injected in this controller (or +injectable without issue) and whether the specific call needed is already a generic method or an +existing custom method - including a custom method on the *target* service's own controller that +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. + +If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user +whether to create it now**. That method's controller action lives on the *target* service - a +different application than this Public API. If the target's Api Client class doesn't exist at all +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. + +**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or +extra logic of its own, call it directly from this controller action - don't add a method to the +target's Api Client class that does nothing but forward to the generic method. This isn't limited +to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on +what came back) is still just generic composition, not custom logic, and reads perfectly fine as +2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +more than one line. A custom Api Client method should only exist when it's paired with a +controller action doing something the generic surface genuinely can't (the Internal service path +below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +or not, just hides what's actually happening for no benefit. + +**Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +to confirm the id exists before calling one is duplicated work the service already does. Only look +something up first if the action needs data the built-in call doesn't already return, or needs to +enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops +a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off +rather than a silent one. + +### Request DTO (if the action takes parameters) + +Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api +Client call happens inside the same action. + +```csharp +public class Request +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +`[Required]` on anything that must be present; match the nullable-reference style already used by +sibling `Request` classes in the same project. See **Shared DTO conventions** above for what else +belongs on this class. + +### Response DTO (if the action returns a body) + +Location: `Responses//Response.cs`, same project. A plain POCO (no base type +required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +unless that's genuinely what the sibling conventions in this project do. See **Shared DTO +conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. + +### Controller action + +Add to an existing Public API controller, or create a new one deriving from `BaseController` if no +suitable controller exists yet: + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The request. +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route("")] +[ProducesResponseType(typeof(Response), (int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task Async([FromBody][Required] Request request, CancellationToken cancellationToken = default) +{ + // Compose injected Api Client(s). + + return this.Ok(response); +} +``` + +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - + match what sibling actions in the same controller declare, don't pad the list. +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it + calls another application's endpoint in that state, that target endpoint must itself be + `[AllowAnonymous]`; note that requirement in a comment. +- **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding + caveat: if this action needs a piece of the caller's identity further downstream, **read it + here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field + on the outgoing custom request - don't rely on the target service re-extracting the same claim + from the JWT Nano forwards alongside the call. + +--- + +## Internal service path + +This action **is** a new piece of contract another application will call - scaffolding it means +scaffolding both halves together: the controller action, and the paired Api Client custom +request/method that lets other applications actually call it. + +### Does this app's own Api Client class exist yet? + +Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If +none exists, create the bare class inline as part of this same change - it's boilerplate with no +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. + +### Shared body model + +If the action takes parameters, define the payload **once**, as a plain model class in +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind +to, not two separate DTOs kept in sync by hand: + +```csharp +public class +{ + [Required] + public virtual { get; set; } = ...; +} +``` + +See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, +prefer returning the target entity/collection directly (same section) - a +`Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't +come from the entity itself. + +### Api Client request and method + +`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute +(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, wrapping the shared body +model from above: + +```csharp +[PostAction(MyActionRoutes.MY_ACTION)] +public class MyActionRequest : BaseRequest +{ + [Body] + public virtual MyAction Model { get; set; } = null!; + + public MyActionRequest() + { + this.Controller = "MyEntities"; + } +} +``` + +**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from +the pluralized `TResponse` type name - this works fine whenever a custom request's response +genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity +controller that still returns that entity). Set `this.Controller` explicitly in the constructor +only in the two cases where inference can't land correctly: +- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer + from. +- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is + a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* + controller than the one the response type's name would imply. + +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. + +**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and +reference it from both this request's action attribute and the controller action's `[Route(...)]` +below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +avoid drift exactly this way. + +Add the corresponding method to this app's own Api Client class: + +```csharp +public virtual Task MyActionAsync(MyAction model, CancellationToken cancellationToken = default) +{ + return this.InvokeAsync(new MyActionRequest + { + Model = model + }, cancellationToken); +} +``` + +One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no +response) or `this.InvokeAsync(request, cancellationToken)` (typed response). +Give the method and its doc comment the same one-liner-summary treatment as the controller action +- name what it does and, if it exists only because the generic surface couldn't express it, why. + +**If the caller needs to tell "not found" apart from "found but empty," keep the method's return +type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +`TResponse`, which for a collection response means `null` (not-found) is already distinguishable +from an empty collection (found, nothing to return) with no extra plumbing. Have the controller +action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the +client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). + +**The method's parameter is the shared body model itself, not its properties spread out as +separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not +`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +model (previous section) is that it *is* the contract's shape; re-exploding it into scalar +parameters here just to reconstruct the same object one line later is pointless indirection, and +it makes the client method's signature drift from the model instead of just being it. Only +parameters that sit *outside* the body model itself (e.g. an `[FromQuery]`/route-bound id like +`tenantId`, which the request's own `[Query]`/`[Route]` property carries separately from `[Body]`) +belong as their own parameter alongside the model. + +**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding +caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an +explicit property on the shared body model, populated by the calling application from its own +JWT - don't design this request to assume this controller will re-derive it from the forwarded +token instead. Note in the doc comment which claim the caller is expected to supply and why. + +**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. +during another app's own login flow), note in the request's doc comment that the controller +action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +it, only document the expectation. + +### Controller action + +```csharp +/// +/// One-line summary of what this action does. +/// +/// The . +/// The cancellation token. +/// The response. +/// OK. +/// Bad Request. +/// Unauthorized. +/// Error occurred. +[HttpPost] +[Route(MyActionRoutes.MY_ACTION)] +[ProducesResponseType((int)HttpStatusCode.OK)] +[ProducesResponseType((int)HttpStatusCode.Unauthorized)] +[ProducesResponseType((int)HttpStatusCode.BadRequest)] +[ProducesResponseType((int)HttpStatusCode.InternalServerError)] +public virtual async Task MyActionAsync([FromBody][Required] MyAction model, CancellationToken cancellationToken = default) +{ + // Use IRepository/IEventing directly. + + return this.Ok(); +} +``` + +- Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming + (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an + entity that already exists but has no generic controller yet still gets its full generic + controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the + action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary + constructor's `repository`/`eventing` parameters are already passed to the base constructor: + referencing the same parameter again inside a method captures it a second time and is a compile + error (CS9107 - "captured into the state of the enclosing type and its value is also passed to + the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for + exactly this reason - use those instead. +- Only include the `[ProducesResponseType]`s actually reachable by this action's logic. +- **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A + plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per + AGENTS.md's Api Clients Gotchas and Error Handling sections: a `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`) + instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling + Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a + not-found case that specifically needs a message/code rather than a bare 404: + `Nano.Data.Abstractions.Exceptions.NotFoundException`. +- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly + which generic route it collides with (verb + path + which AGENTS.md table row), and leave both + in place for the user to resolve. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. + `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: + `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides + exactly the same way a generic CRUD route would - flag it the same way. +- **The one exception: a deliberate override, not a collision.** Every generic CRUD action is + `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the + base action already does, plus a little extra" - e.g. create the entity, then also publish a + custom event - the correct approach is to **override the base method** (call the base + implementation, or reproduce its persistence step, then add the extra behavior) on the *same* + route, not scaffold a separate custom action that happens to reuse it. An override isn't a + collision at all - same method, same route, extended behavior - so there's nothing to flag. + Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's + doing something meaningfully different at that route, that's a real collision per the rule + above, not an override candidate. This stays the exception, not the default - most custom + actions should still avoid the base routes entirely; don't reach for an override as a shortcut + to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern + right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + +#### Overriding a generic CRUD action instead of a new endpoint + +The case above generalizes into a real alternative to scaffolding a new custom action: whenever +the actual requirement is "the same generic write, plus an invariant that must hold no matter which +caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant +`BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to +them. This enforces the rule as a property of the *entity's own controller*, so it holds for every +consumer, not just the one Public API that remembered to compose it. + +- **Cover every generic write variant the invariant must survive, not just the one your current + caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity + variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, + `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ + query-based variants - decide per case whether those are reachable/relevant enough to matter). + If the invariant genuinely must always hold, override all of the single-entity variants a caller + could plausibly reach; overriding only the one your current Public API calls leaves the same gap + a new custom endpoint would have needed to close anyway, just via a different route. +- **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s + constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before + calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking + row) instead of trying to extract an id back out of the base call's `IActionResult`. +- **The override's signature is fixed by the base method - there's no room to thread extra + caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ + `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a + bespoke custom action could. If per this solution's convention a downstream service doesn't + parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and + passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + generic-action override can only enforce invariants derivable from the entity/data itself + (permission-subset validation, reference-count guards, linking rows) - it can't perform + tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a + scoped `QueryFirst`) before calling the generic write; the override and the Public API check are + complementary, not either-or. +- **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume + this pattern only protects against removing something still in use - "reject adding a child row + 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 + non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of + `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another + entity's existence, check both directions before assuming only deletes need guarding. +- **Reconciling a collection navigation is still an explicit step inside the override.** The same + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update + scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or + after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed + to. Overriding moves *where* this logic lives, not whether it's still needed. +- **Duplicate the validation across each overridden variant rather than extracting a shared private + helper**, if that's this project's established preference for controllers (confirm against + existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. +- Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these + overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + error comes from a bespoke custom action or an overridden generic one. +- **If this action's route collides with another custom action's route** (same controller, same + route+verb): a genuine defect in the request-side contract, not something to silently rename or + merge. Scaffold both anyway, with a prominent comment on each naming the other action it + collides with - flag it for the user to resolve rather than guessing. +- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + from this app's own JWT/`HttpContext` if this action needs them for something *further* + downstream (e.g. calling yet another service) - this note is about what the *caller* already + supplied explicitly on the request, which is the normal case for an internal-service action's + own use of caller context. + +--- + +## After generating + +- Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller + action, and - for the internal-service path - the shared body model, the Api Client request, + and the Api Client method) and which project each lives in. +- **Internal service path**: state plainly that this scaffolds the contract, not the business + logic - the controller action's body is a stub unless the user asked for the real + implementation too. +- **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't + decided on it yet, that's the natural stopping point - don't scaffold the controller action + against a call that doesn't exist, and don't guess at its shape. +- If step 1 found the generic surface already covers this, that's the whole response - explain + what already does the job instead of generating anything. 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 new file mode 100644 index 00000000..fecad9a6 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -0,0 +1,523 @@ +--- +mode: agent +description: Add a Nano data provider (MySql, PostgreSQL, SqlServer, SqLite, or InMemory) to a Nano.Library-based application - registers it in Program.cs, adds the DbContext/DbContextFactory, the Data configuration section, the local docker-compose database service, and (for MySql/PostgreSQL/SqlServer) the Staging/Production migration CI step and Kubernetes secret. Use when the user asks to add a database, persistence, or a specific data provider to a Nano API, Web, or Console application. +--- + +# Nano add data provider + +Wires a Nano data provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first - its `## Nano.Data` section documents the +`Configuration` table, the provider/package table, the exact `DbContext`/`BaseDbContextFactory` +shapes, and `Migrations`/`StartupAction` semantics in full; this skill does not repeat any of +that, only how to apply it and wire the surrounding infrastructure (docker-compose, CI, K8s) +without breaking what's already there. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see + AGENTS.md's provider table for package/type names). Ask the user if not already given. +3. **Is a data provider already registered?** Check `Program.cs` for an existing + `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically + wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm + with the user whether they want to *replace* it (single-context swap) or genuinely add a + second `DbContext` before proceeding either way. +4. **Is a package reference even needed?** Same check as the logging skill: look for a + `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application + project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package + step. Otherwise add `` to + the **application project** (never `.Models`), matching the version of the project's existing + Nano application-type package. Never add a `ProjectReference` to Nano.Library source. +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. + +## Program.cs + +Add the registration inside the existing `.ConfigureServices(...)` lambda (same placeholder/`_` +→ `x` rename rule as the logging skill if the lambda is still the blank-app boilerplate): + +```csharp +using Nano.Data.Extensions; +using Nano.Data.; +using .Data; +``` + +```csharp +x.AddNanoData<Provider, DbContext>(); +``` + +## Data Context and design-time factory + +Create both files in `Data/` in the application project (per AGENTS.md's `### Data Context` +and `#### Design-time factory (migrations)` - copy those shapes exactly, they're not +provider-specific except for the generic arguments): + +- `Data/DbContext.cs` - thin subclass of `BaseDbContext`/`BaseDbContext` with + the exact `(DbContextOptions, IOptionsMonitor)` constructor AGENTS.md shows. Skip + this file for `InMemory` only if the project has no other provider-specific needs - check + AGENTS.md's provider table notes first (`InMemory` still needs a `DbContext`, just no + `BaseDbContextFactory` or migrations). +- `Data/DbContextFactory.cs` - subclass of `BaseDbContextFactory`. + Skip for `InMemory` (no migrations to design-time-construct against). + +## appsettings.json + +Add the `Data` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`). A few placements are load-bearing, not arbitrary: + +- **`ConnectionString`**: leave `null` in the base file; set the real value only in + `appsettings.Development.json` (see below) - never commit a real connection string to the + base file. +- **`AuthenticationType`**: always `"Credentials"` in the base `appsettings.json`, even for + providers that will use Managed Identity in Staging/Production - `Credentials` is the correct + *local* value, and it's what the base file should show either way. Don't set `"Azure"` here; + live environments override it via the Kubernetes ConfigMap (see the Staging/Production + section below), never via a static appsettings file. +- **`StartupAction`**: `"None"` in the base file. Only `appsettings.Development.json` sets it to + `"Migrate"` (AGENTS.md: only enable `Create`/`Migrate` in `Development`). + +In `appsettings.Development.json`, add: + +```json +"Data": { + "StartupAction": "Migrate", + "ConnectionString": "" +} +``` + +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. + +⚠ 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 +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. + +## Initial migration + +Skip for `InMemory`. Otherwise, after the `DbContext`/factory files exist: + +```powershell +dotnet ef migrations add Initial --project {project} +``` + +Only run this if the user asked you to, or the project's existing workflow clearly expects a +migration per provider setup - check for a `Migrations/` folder precedent first (per the +entity-scaffold skill's same rule). + +## docker-compose.yml (local Development) + +Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. + +```yaml +# MySql +database: + image: mysql/mysql-server:latest + ports: + - 3306:3306 + networks: + - network + environment: + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: myPassword_123 + +# PostgreSQL +database: + image: postgis/postgis:latest + ports: + - 5432:5432 + networks: + - network + environment: + POSTGRES_USER: sa + POSTGRES_PASSWORD: myPassword_123 + POSTGRES_DB: nanoDb + +# SqlServer +database: + image: mcr.microsoft.com/mssql/server:2022-latest + ports: + - 1433:1433 + networks: + - network + environment: + SA_PASSWORD: myPassword_123 + ACCEPT_EULA: Y + MSSQL_PID: Developer +``` + +`SqLite`/`InMemory` need no `database` service - SqLite persists to a local/mounted file, not a +server container. + +## SqLite (Kubernetes persistent volume, not a migration CI step) + +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +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 + 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): + ```yaml + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: %SERVICE_NAME%-data-storage-class + provisioner: disk.csi.azure.com + parameters: + storageaccounttype: Standard_LRS + kind: Managed + reclaimPolicy: Retain + volumeBindingMode: WaitForFirstConsumer + ``` +- **`ReadWriteOnce`, one disk per pod, not one shared disk.** This disk can only attach to a + single pod - so if the app runs more than one replica, `deployment.yaml`'s `kind: Deployment` + is wrong (every replica shares one pod template and would race to attach the same static PVC; + only the first pod to schedule ever becomes ready). Use `stateful-set.yaml` + (`kind: StatefulSet`) with `volumeClaimTemplates` instead - giving each replica its own + separate disk, and its own separate SqLite database file (not shared across replicas; if the + app needs one *shared* database, that's what the network providers above are for). +- **`.kubernetes/stateful-set.yaml`**: add `serviceName: %SERVICE_NAME%-stateful-headless` alongside + `replicas`/`selector`, mount the volume in the container: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/data + ``` + and, as a top-level sibling of `template:` (not nested inside `template.spec`): + ```yaml + volumeClaimTemplates: + - metadata: + name: %SERVICE_NAME%-volume + spec: + accessModes: + - ReadWriteOnce + storageClassName: %SERVICE_NAME%-data-storage-class + resources: + requests: + storage: %SQL_SIZE%Gi + ``` + Needs `SQL_SIZE: 10` (a bare number of GB, e.g. `10` - the template above appends `Gi`; or + whatever size the user wants) added to the workflow env block. +- **`.kubernetes/service-headless.yaml`** (new file) - required by the `StatefulSet`'s + `serviceName` field, separate from the app's normal `ClusterIP` service: + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: %SERVICE_NAME%-stateful-headless + namespace: %KUBERNETES_NAMESPACE% + spec: + clusterIP: None + ports: + - name: http + port: 8080 + selector: + app: %SERVICE_NAME% + ``` +- **`.kubernetes/autoscaler.yaml`** - always present on an API/Web app (per AGENTS.md's Solution + Structure), the only app types this `StatefulSet` conversion ever applies to: change + `scaleTargetRef.kind` from `Deployment` to `StatefulSet`. +- **Kubernetes Deploy workflow step**: apply `data-storageclass.yaml` (still needed - referenced + by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | + ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before + `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. +- No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` + change - none of the Staging/Production section below applies to SqLite. + +## InMemory (nothing further) + +No `DbContext`/factory beyond the plain `DbContext` itself, no migrations, no `docker-compose` +service, no Kubernetes changes, no Staging/Production section. `Program.cs` registration and the +base `Data` config (with `ConnectionString` left `null`) are the entire job. + +## Staging/Production (MySql, PostgreSQL, SqlServer only - SqLite/InMemory covered above) + +This section assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` +- a separate, prerequisite skill: service-account.yaml, workload-identity annotations, the CI +"Managed Identity" step that produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/ +`$env:IDENTITY_PRINCIPAL_ID`). If the project doesn't have that yet, point the user at that skill +first rather than wiring a migration step that references identity variables that don't exist. + +It also assumes the target Azure database **server** resource already exists (a MySQL/PostgreSQL +Flexible Server, or an Azure SQL **server** - not the same as the individual database on it). +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: + AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} + DOTNET_EF_TOOLS_VERSION: "10.0" + ``` + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. If this is *replacing* an existing provider, + remove that provider's migration step (and any env vars only it needed) rather than leaving it + disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure + server, runs `dotnet ef database update` using an elevated/admin credential, then grants the + app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds + the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: + + ```yaml + - name: MySQL Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].databasePort -o tsv; + $env:SQL_SERVER = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + $env:SQL_USER = az mysql flexible-server ad-admin list -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER --query "[0].login" -o tsv; + $env:SQL_TOKEN = az account get-access-token --resource-type oss-rdbms --query accessToken -o tsv; + + $env:DATA__CONNECTIONSTRING = "Server=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Uid=$env:SQL_USER;Pwd=$env:SQL_TOKEN;SslMode=Required"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:APP_USER_SQL_PATH = "app-database-user.sql"; + $sql = @" + CREATE AADUSER IF NOT EXISTS '$env:IDENTITY_NAME' IDENTIFIED BY '$env:IDENTITY_CLIENT_ID'; + GRANT SELECT, INSERT, UPDATE, DELETE ON $env:SQL_NAME.* TO '$env:IDENTITY_NAME'@'%'; + FLUSH PRIVILEGES; + "@; + $sql | Set-Content $env:APP_USER_SQL_PATH; + + az mysql flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN --file-path $env:APP_USER_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:SQL_CONNECTIONSTRING = "Server=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Uid=$env:IDENTITY_NAME;SslMode=Required"; + echo "SQL_CONNECTIONSTRING=$env:SQL_CONNECTIONSTRING" >> $env:GITHUB_ENV; + ``` + + ```yaml + - name: PostgreSQL Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = 5432; + $env:SQL_SERVER = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + $env:SQL_USER = az postgres flexible-server microsoft-entra-admin list -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER --query "[0].principalName" -o tsv; + $env:SQL_TOKEN = az account get-access-token --resource-type oss-rdbms --query accessToken -o tsv; + + $env:DATA__CONNECTIONSTRING = "Host=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Username=$env:SQL_USER;Password=$env:SQL_TOKEN;SSL Mode=Require;Trust Server Certificate=true"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:PRINCIPAL_SQL_PATH = "app-database-principal.sql"; + $env:GRANTS_SQL_PATH = "app-database-grants.sql"; + $principalSql = @" + DO `$`$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$env:IDENTITY_NAME') THEN + PERFORM pgaadauth_create_principal('$env:IDENTITY_NAME', false, false); + END IF; + END + `$`$; + "@; + $principalSql | Set-Content $env:PRINCIPAL_SQL_PATH; + az postgres flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN -d postgres --file-path $env:PRINCIPAL_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $grantsSql = @" + GRANT CONNECT ON DATABASE "$env:SQL_NAME" TO "$env:IDENTITY_NAME"; + GRANT USAGE ON SCHEMA public TO "$env:IDENTITY_NAME"; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "$env:IDENTITY_NAME"; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "$env:IDENTITY_NAME"; + "@; + $grantsSql | Set-Content $env:GRANTS_SQL_PATH; + az postgres flexible-server execute -n $env:SQL_SERVER -u $env:SQL_USER -p $env:SQL_TOKEN -d $env:SQL_NAME --file-path $env:GRANTS_SQL_PATH; + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:SQL_CONNECTIONSTRING = "Host=$env:SQL_HOST;Port=$env:SQL_PORT;Database=$env:SQL_NAME;Username=$env:IDENTITY_NAME;SSL Mode=Require;Trust Server Certificate=true"; + echo "SQL_CONNECTIONSTRING=$env:SQL_CONNECTIONSTRING" >> $env:GITHUB_ENV; + ``` + + `SqlServer` needs **two** steps, not one - unlike MySQL/PostgreSQL Flexible Server (one + server hosts many databases, and EF's `database update` can create the database itself), + Azure SQL treats each database as its own billable resource that must be explicitly created + first: + + ```yaml + - name: SQL Server Create Database + shell: pwsh + run: | + $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; + $env:SQL_EDITION = "GeneralPurpose"; + $env:SQL_MAX_SIZE = "64GB"; + $env:SQL_BACKUP_RETENTION = "35" + $env:SQL_SERVER_NAME = az sql server list -g $env:AZURE_GROUP_DATABASE --query "[0].name" -o tsv; + $env:SQL_DB_EXISTS = az sql db show -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --query name -o tsv 2>$null; + + if (-not $env:SQL_DB_EXISTS) + { + az sql db create ` + -n $env:SQL_NAME ` + -s $env:SQL_SERVER_NAME ` + -g $env:AZURE_GROUP_DATABASE ` + --edition $env:SQL_EDITION ` + --service-objective $env:SQL_SERVICE_OBJECTIVE ` + --max-size $env:SQL_MAX_SIZE ` + --backup-storage-redundancy Geo ` + --zone-redundant true; + + $env:MAINTENANCE_CONFIG_ID = "/subscriptions/$env:AZURE_SUBSCRIPTION_ID/providers/Microsoft.Maintenance/publicMaintenanceConfigurations/SQL_Default"; + + az sql db update ` + -n $env:SQL_NAME ` + -s $env:SQL_SERVER_NAME ` + -g $env:AZURE_GROUP_DATABASE ` + --maint-config-id $env:MAINTENANCE_CONFIG_ID; + + $env:DIAGNOSTIC_SETTINGS_NAME = "diagnostics-" + $env:SQL_NAME; + $env:SQL_LOGS_PATH = "sql-diagnostic-logs.json"; + $env:SQL_METRICS_PATH = "sql-diagnostic-metrics.json"; + $env:WORKSPACE_ID = az monitor log-analytics workspace list -g $env:AZURE_GROUP_LOGS --query [0].[id] -o tsv; + $env:SQLDB_ID = az sql db show -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --query id -o tsv; + + $logsJson = @" + [ + { "category": "QueryStoreRuntimeStatistics", "enabled": true }, + { "category": "SQLSecurityAuditEvents", "enabled": true } + ] + "@; + $logsJson | Set-Content $env:SQL_LOGS_PATH; + + $metricsJson = @" + [ + { "category": "Basic", "enabled": true }, + { "category": "InstanceAndAppAdvanced", "enabled": true }, + { "category": "WorkloadManagement", "enabled": true } + ] + "@; + $metricsJson | Set-Content $env:SQL_METRICS_PATH; + + az monitor diagnostic-settings create ` + --name $env:DIAGNOSTIC_SETTINGS_NAME ` + --resource $env:SQLDB_ID ` + --workspace $env:WORKSPACE_ID ` + --logs "@$env:SQL_LOGS_PATH" ` + --metrics "@$env:SQL_METRICS_PATH"; + + $env:ACTION_GROUP = az monitor action-group list -g $env:AZURE_GROUP_LOGS --query [0].[id] -o tsv; + + az monitor metrics alert create --name "High CPU Usage" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg cpu_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when CPU usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Memory And Worker Usage" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg workers_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when worker/session usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Number Of Connections" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "total connection_successful > 100" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when the number of successful connections exceeds 100 in 5 minutes."; + az monitor metrics alert create --name "High Storage IO" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg physical_data_read_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when data IO usage is above 80% for 5 minutes."; + az monitor metrics alert create --name "High Storage Percent" --resource-group $env:AZURE_GROUP_DATABASE --scopes $env:SQLDB_ID --condition "avg storage_percent > 80" --window-size PT5M --evaluation-frequency PT1M --action $env:ACTION_GROUP --severity 2 --description "Alert when Storage usage exceeds 80% for 5 minutes."; + + az sql db str-policy set -g $env:AZURE_GROUP_DATABASE -s $env:SQL_SERVER_NAME -n $env:SQL_NAME --retention-days $env:SQL_BACKUP_RETENTION; + + if ($LastExitCode -ne 0) { throw "error"; }; + }; + ``` + + This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only + acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. + + ```yaml + - name: SQL Server Database Migration + shell: pwsh + run: | + $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; + $env:SQL_PORT = 1433; + $env:SQL_SERVER = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].name -o tsv; + + $env:DATA__CONNECTIONSTRING = "Server=$env:SQL_HOST,$env:SQL_PORT;Database=$env:SQL_NAME;Authentication=Active Directory Service Principal;User Id=$env:AZURE_CLIENT_ID;Password=$env:AZURE_CLIENT_SECRET;Encrypt=True;TrustServerCertificate=True;"; + + & "/opt/ef-tools/$env:DOTNET_EF_TOOLS_VERSION/dotnet-ef" database update ` + --no-build ` + --configuration Release ` + --startup-project $env:APP_NAME ` + -- ` + --environment $env:ASPNETCORE_ENVIRONMENT; + + if ($LastExitCode -ne 0) { throw "error"; }; + ``` + + ⚠ Unlike MySQL/PostgreSQL, this reference implementation doesn't build a passwordless + `SQL_CONNECTIONSTRING`/grant step for SQL Server afterward - it runs the migration with the + service principal's own credentials and stops there. If the user wants runtime + Managed-Identity auth for SQL Server specifically rather than the service-principal + credentials shown, flag that as a gap to resolve with them rather than inventing the missing + grant step. + +3. **Kubernetes secret** - add `.kubernetes/auth-sql-secret.yaml`: + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: %SERVICE_NAME%-sql-auth-secret + namespace: %KUBERNETES_NAMESPACE% + type: Opaque + stringData: + data-connectionstring: %SQL_CONNECTIONSTRING% + ``` + Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | + Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. +4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. + This is what actually makes the live environment use `Azure` auth - the base + `appsettings.json` stays `Credentials` always (see above); this env var overrides it at + runtime. +5. **Deployment** - add to `.kubernetes/deployment.yaml`'s container `env`: + ```yaml + - name: Data__ConnectionString + valueFrom: + secretKeyRef: + name: %SERVICE_NAME%-sql-auth-secret + key: data-connectionstring + ``` + +## After making the change + +- Show the user every file touched, grouped by concern (app code, local docker-compose, + Staging/Production CI + K8s) - this skill touches more files than most, so a flat list is + harder to sanity-check than a grouped one. +- If package/DbContext/migration steps were skipped (`InMemory`, or `NanoCore`/`Nano.All` + already covering the package), say so explicitly. +- If the Staging/Production section was skipped because Managed Identity isn't set up yet (point + the user at `nano-add-azure-managed-identity`), or because the SQL Server target database doesn't + exist, say so explicitly rather than silently doing only the local-dev half of the job. 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 new file mode 100644 index 00000000..75ed9535 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` 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. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** 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. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-event-handler.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-eventing-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-eventing-provider.prompt.md new file mode 100644 index 00000000..dbdbee6e --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-eventing-provider.prompt.md @@ -0,0 +1,156 @@ +--- +mode: agent +description: Add a Nano eventing provider (currently only RabbitMq) to a Nano.Library-based application - registers it in Program.cs, adds the Eventing configuration section, the local docker-compose broker service, and the Kubernetes secret reference for Staging/Production. Use when the user asks to add eventing, pub/sub messaging, or a message broker to a Nano API, Web, or Console application. +--- + +# Nano add eventing provider + +Wires a Nano eventing provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first - its `## Nano.Eventing` section documents the +`Configuration` table, the provider/package table, and `Publish and Subscribe`/`BaseEventHandler` +usage in full; this skill does not repeat any of that, only how to apply it and wire the +surrounding infrastructure (docker-compose, K8s) without breaking what's already there. + +Considerably simpler than the data-provider skill: there's no CI migration step, no Managed +Identity pairing, and no per-app secret to create - RabbitMQ credentials come from a +**pre-existing, shared, cluster-wide** Kubernetes secret, not something this skill provisions. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user + names something else, check whether a custom provider already exists in the project first, + per AGENTS.md's `#### Custom eventing provider` section). +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing + `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here + (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` + implementation). If one is already registered, treat this as a replace and say so, the same + as the logging skill. +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for + `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the + package step. Otherwise add `` + to the **application project**, matching the version of the project's existing Nano + application-type package. Never a `ProjectReference` to Nano.Library source. + +## Program.cs + +```csharp +using Nano.Eventing.Extensions; +using Nano.Eventing.RabbitMq; +``` + +```csharp +x.AddNanoEventing(); +``` + +Same `.ConfigureServices(...)` lambda placement and `_` → `x` placeholder-rename rule as the +other add-provider skills. + +## appsettings.json + +Add the `Eventing` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`), with one placement split that example doesn't spell out - +unlike the Data provider's `ConnectionString`, most of this section is **not** sensitive: + +- `Host` stays filled in (`"rabbitmq"`, the docker-compose service name below) at the **base** + level - it's not sensitive, and it's already the correct value for local `Development`, so no + `appsettings.Development.json` override is needed for it. Staging/Production override `Host` + (and everything else) via the Kubernetes secret below, not a static appsettings file. +- Only `Credentials.Id`/`Credentials.Secret` are secret - leave them `null` in the base file, and + set the real local values (matching the docker-compose broker's own credentials below) in + `appsettings.Development.json`. +- Include `HealthCheck` only if the project's `App.HealthCheck` is actually enabled - adding a + dependency-level health check when nothing reads the app-level `/healthz` endpoint is dead + configuration that Kubernetes probes would point at without effect. If unsure, check + `Program.cs`'s `.ConfigureApp()` chain / the base `appsettings.json` for `App.HealthCheck` first. + +## docker-compose.yml (local Development) + +Add an `eventing` service to `.docker/docker-compose.yml`, and add it to the app's own service's +`depends_on` if not already present: + +```yaml +eventing: + image: rabbitmq:management + hostname: rabbitmq + ports: + - 5671:5671 + - 5672:5672 + - 15671:15671 + - 15672:15672 + networks: + - network + environment: + RABBITMQ_DEFAULT_USER: rabbitmq_user + RABBITMQ_DEFAULT_PASS: password + RABBITMQ_DEFAULT_VHOST: / +``` + +`hostname: rabbitmq` is why the base `appsettings.json`'s `Eventing:Host` can just be +`"rabbitmq"` without a Development-specific override - it resolves directly on the compose +network. + +## Existing entity controllers + +Per AGENTS.md's `#### Entity controller hierarchy`, every entity controller's constructor +already has a place for `IEventing? eventing = null` - it's optional, so a controller written +before eventing existed simply omits it. Now that a provider is registered, retrofit every +existing entity controller (the full `BaseEntity*Controller`/`BaseEntityUserController` hierarchy +- check each for a constructor that's missing the parameter) to add it, so they can publish +events without a second pass later: + +```csharp +public class MyEntitysController(ILogger logger, IRepository repository, IEventing? eventing) + : BaseEntityController(logger, repository, eventing); +``` + +For a `BaseEntityUserController` (see `nano-add-identity`), `eventing` goes between `repository` +and `identityRepository`, in that order. This is purely additive and safe - the parameter is +nullable, so it doesn't change behavior for a controller that never ends up using it. + +## Staging/Production (Kubernetes) + +No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: + +Add to `.kubernetes/deployment.yaml`'s container `env`: + +```yaml +- name: Eventing__Host + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: host +- name: Eventing__Port + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: port +- name: Eventing__Credentials__Id + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: username +- name: Eventing__Credentials__Secret + valueFrom: + secretKeyRef: + name: rabbitmq-default-user + key: password +``` + +## After making the change + +- Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + + Development), the docker-compose `eventing` service, the `deployment.yaml` env entries, and + every entity controller retrofitted with `IEventing? eventing`. +- If a package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. +- Mention `AGENTS.md`'s `Publish and Subscribe` section as the next read if the user also wants + to actually publish/handle events, not just have the broker wired - this skill only wires the + provider, it doesn't scaffold event contracts or handlers. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-health-checks.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-health-checks.prompt.md new file mode 100644 index 00000000..1c37864d --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-health-checks.prompt.md @@ -0,0 +1,82 @@ +--- +mode: agent +description: Enable Nano's built-in health checks (App:HealthCheck) on a Nano API or Web application - adds the config plus the Kubernetes liveness/readiness probes a fresh app ships without. Use when the user asks to add health checks, a /healthz endpoint, or liveness/readiness probes to a Nano API or Web application. +--- + +# Nano add health checks + +Enables Nano's built-in `/healthz` endpoint on an existing Nano API or Web application. Read +AGENTS.md's `#### Health Checks` section first - it documents the response shape and the +health-is-a-tree propagation model in full; this skill is config plus the Kubernetes wiring a +fresh app doesn't have yet. + +**API/Web only.** Console apps have no HTTP pipeline at all - there's nothing to expose `/healthz` +on. If the target is a Console app, stop and say so rather than adding dead config. + +**This is not just a config flip.** `UseNanoHealthChecks` no-ops entirely when `App:HealthCheck` +isn't configured - `/healthz` genuinely doesn't exist without it. A minimal Nano app ships with +**no Kubernetes liveness/readiness probes at all** (verified: `nanocore-api-minimal`'s +`deployment.yaml` has none), specifically because probing a path that doesn't exist would fail +forever and crash-loop the pod. So enabling health checks means adding the config **and** the +probes together - never one without the other. + +## Before making any change, determine + +1. **Application type.** Confirm API or Web via `Program.cs`. Stop for Console. +2. **Is `App:HealthCheck` already configured?** Check the base `appsettings.json`. If present, + check whether `.kubernetes/deployment.yaml`/`stateful-set.yaml` already has the matching + probes - if the config exists but the probes don't (or vice versa), that's the broken + half-state described above; fixing it is this skill's job even though nothing needs "adding" + config-wise. +3. **Any provider health checks waiting to activate?** Check for `HealthCheck` blocks already + present under `Data`/`Eventing`/`Storage`/`App:Apis:{Client}` config - those are dead + configuration until `App:HealthCheck` exists (AGENTS.md: "must also be enabled at the + `App:HealthCheck` level"). Not a blocker, just worth mentioning - they'll start working the + moment this change lands. + +## appsettings.json + +Add to the base `appsettings.json`, sibling of `App:Version`/`App:Hosting`: + +```json +"App": { "HealthCheck": { } } +``` + +No options - presence alone enables it. Same in every environment; no Development-specific +override needed. + +## Kubernetes + +Add liveness and readiness probes to `.kubernetes/deployment.yaml`'s (or `stateful-set.yaml`'s) +container spec: + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + periodSeconds: 10 + initialDelaySeconds: 30 + timeoutSeconds: 2 +readinessProbe: + httpGet: + path: /healthz + port: 8080 + scheme: HTTP + periodSeconds: 5 + initialDelaySeconds: 20 + timeoutSeconds: 2 +``` + +These values (period/delay/timeout) match every existing Nano app with health checks enabled - +match them rather than inventing different numbers unless the user asks for something specific. + +## After making the change + +- Show the user every file touched. +- If step 3 found dormant provider health checks, tell the user explicitly which ones just + became active - they'll now appear in the `/healthz` response tree and can affect the overall + reported status. +- If step 2 found a broken half-state (config without probes, or probes without config), say + clearly what was actually wrong before this fix, not just what was added. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md new file mode 100644 index 00000000..2be816e3 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md @@ -0,0 +1,166 @@ +--- +mode: agent +description: Configure Nano's persistent Identity store (Data:Identity) on a Nano.Library-based application that already has a Data provider - adds the Identity configuration section and the User entity/mapping/controller triplet Nano's identity actions attach to. Use when the user asks to add user accounts, a user store, sign-up, or persistent identity to a Nano API, Web, or Console application - not when they ask for login/JWT/authentication itself, that's a separate concern. +--- + +# Nano add identity + +Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, +only how to apply it and add the `User` entity Nano's identity actions attach to. + +**Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the +persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a +different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and +identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any +way to log in. If the user actually wants login/JWT, that's a different skill. + +## Before making any change, determine + +1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers + § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api + Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes + `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an + internal network - never on an app reachable directly from the internet. If the request is + actually "add login/signup to our Public API," that's **not** this skill: point the user at + composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) + instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint + its own tokens with server-computed claims. Only proceed with this skill once it's confirmed + this app is (or is becoming) the internal service that actually owns the `User` entity. + + **Also check whether this app is already publicly exposed** - look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files + `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant + to expose may have been anyway, or an app built as an internal service may have picked up + public exposure later for an unrelated reason. If either file is present, **stop before + touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + its anonymous password-reset endpoints on the open internet the moment this skill finishes, + not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is + intentional before proceeding. +2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or + provider - with none registered, stop and tell the user a Data provider needs to be added + first (see `nano-add-data-provider`). +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a + separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so + whichever Data provider package is already referenced already carries them. Nothing to add + here. +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` + (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + `IIdentityRepository` must agree with it. +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no + HTTP surface to route identity actions through), unless the user explicitly wants to drive + identity from repository code in a worker. + +## appsettings.json + +Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base +`appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, +the whole section lives in the base file - nothing in it is an environment-specific secret, so +there's no `appsettings.Development.json` split to make. + +- Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity + models to be audited. +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only + once API-key authentication is added on top, which is Authentication's job, not this skill's; + adding it here with nothing consuming it yet is dead config. + +## User entity, mapping, and controller + +This is the entity-scaffold skill's file set, with identity-specific base classes in place of +the plain ones - read that skill first for the file-location/project-layout rules (split +`.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity +name if not given (conventionally `User`) and any additional properties beyond what +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. + +- **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. +- **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from + `BaseEntityUserMapping`/`` (namespace + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + configures the required 1:1 relationship to the underlying `IdentityUser` row and an + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + identity-specific here. +- **Controller** (API/Web only, `Controllers/sController.cs`): derive from + `BaseEntityUserController`/`` (namespace + `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional + constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: + ```csharp + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, eventing, identityRepository); + ``` + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` + table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints + that don't match the current configuration (e.g. API-key management when API-key auth isn't + enabled) aren't registered at all - nothing further to do for those until that's added. + +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + +## After making the change + +- Show the user every file touched. +- Remind them explicitly: this adds accounts and identity-management endpoints, but no way to + log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints + are reachable by an actual caller. +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire + Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-logging-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-logging-provider.prompt.md new file mode 100644 index 00000000..f3010507 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-logging-provider.prompt.md @@ -0,0 +1,80 @@ +--- +mode: agent +description: Add a Nano logging provider (Log4Net, Microsoft, NLog, or Serilog) to a Nano.Library-based application - registers the provider in Program.cs and adds the Logging configuration section to appsettings.json. Use when the user asks to add logging, set up a specific logging provider, or switch the logging provider in a Nano API, Web, or Console application. +--- + +# Nano add logging provider + +Wires a Nano logging provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first - it documents `Nano.Logging`'s registration +one-liner, the four providers' package names, and the exact `Logging` config shape/defaults +under its `## Nano.Logging` section; this skill does not repeat any of that, only how to apply +it correctly to an existing project without breaking what's already there. + +## Before making any change, determine + +1. **Which provider.** One of `Log4Net`, `Microsoft`, `NLog`, `Serilog` (see AGENTS.md's + provider table for the package/type names). Ask the user if not already given. +2. **Is a provider already registered?** Nano supports exactly one logging provider at a + time - check `Program.cs` for an existing `.AddNanoLogging<...>()` call. If one exists for + a *different* provider, tell the user this will replace it (remove the old `using`, + provider call, and reference) rather than silently adding a second one. If it's already the + *same* provider, say so and stop - nothing to do. +3. **Is a package reference even needed?** Check whether the provider's type already resolves + without adding anything: look for a `PackageReference` to `NanoCore` or `Nano.All` (they're + identical, see AGENTS.md) on the application project itself, or on a `.Models` project it + reaches via `ProjectReference` (AGENTS.md's "quick start" convention - either package pulls + in every Nano package, including every logging provider, transitively). If found, **no + package change is needed at all** - skip straight to Program.cs. + - Otherwise, the project uses the explicit/granular convention: add + `` to the + **application project's** `.csproj` (never a `.Models` project - per AGENTS.md, providers + belong on the app project, `Nano.App` is the only Nano package `.Models` needs), using the + **exact same version** as the project's existing `Nano.App.Api`/`Nano.App.Web`/ + `Nano.App.Console` reference. Don't invent or guess a version. + - Never add a `ProjectReference` to Nano.Library source - always a NuGet `PackageReference`, + even if the rest of the project currently references Nano.Library from source. Some + internal Nano.Library development repos do that for their own convenience but explicitly + document it as something to replace with NuGet packages before deployment - it's not the + convention to extend into a new reference. + +## Program.cs + +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. + +- If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ + => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and + remove the placeholder comment - `x` is the Nano convention once the lambda holds a real + registration. +- If other real service registrations already exist in the lambda, just add the + `AddNanoLogging<...>()` call alongside them; don't touch unrelated lines. +- Works identically for `NanoApiApplication`, `NanoWebApplication`, and `NanoConsoleApplication` + - the `.ConfigureServices(...)` call and `AddNanoLogging<...>()` registration are the same + across all three app types (`NanoWebApplication` extends `NanoApiApplication`). + +## appsettings.json + +Add the `Logging` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` only (sibling of `App`, not nested inside it) - no environment-overlay file +needs it. + +- If a `Logging` section already exists (e.g. from a previously-registered different provider), + leave its `LogLevel`/`LogLevelOverrides` values as-is - they're provider-agnostic - and only + touch `Program.cs` and the package/project reference. + +## After making the change + +- Show the user the modified `Program.cs` lines, the `appsettings.json` addition, and - if one + was needed - the `PackageReference` added to the `.csproj`. If none was needed (NanoCore/ + Nano.All already covers it), say so explicitly rather than leaving it unmentioned. +- If this replaced a different provider, explicitly list what was removed (old `using`, + provider call, and reference if one was added for it) alongside what was added, so the user + can sanity-check the swap. +- Don't add any package beyond the logging provider itself, and don't touch Docker/Kubernetes/CI + files - logging provider selection has no effect on any of those. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-metrics.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-metrics.prompt.md new file mode 100644 index 00000000..069ccea4 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-metrics.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Enable Nano's built-in OpenTelemetry metrics (App:Metrics) on a Nano API or Web application - adds the config and the Kubernetes ServiceMonitor for Prometheus scraping. Use when the user asks to add metrics, Prometheus, OpenTelemetry, or a /metrics endpoint to a Nano API or Web application. +--- + +# Nano add metrics + +Enables Nano's built-in `/metrics` endpoint (Prometheus-compatible, via OpenTelemetry) on an +existing Nano API or Web application. Read AGENTS.md's `#### Metrics (OpenTelemetry)` section +first; this skill is just the wiring. + +**API/Web only** - same reasoning as Health Checks: Console apps have no HTTP pipeline, so +there's no `/metrics` to expose. + +**Independent of Health Checks.** Verified directly against the registration code +(`AddNanoMetrics`/`UseNanoMetrics`) - Metrics has no dependency on `App:HealthCheck` in either +direction. Enable it on its own; don't add Health Checks "because Metrics needs it" - it doesn't. + +## Before making any change, determine + +1. **Application type.** Confirm API or Web via `Program.cs`. Stop for Console. +2. **Is `App:Metrics` already configured?** Check the base `appsettings.json`. If present, check + whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" + concern as Health Checks, though less severe here since nothing actively breaks without the + `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. + +## appsettings.json + +Add to the base `appsettings.json`, sibling of `App:Version`/`App:Hosting`: + +```json +"App": { "Metrics": { } } +``` + +No options - presence alone enables it. Same in every environment. + +## Kubernetes + +`.kubernetes/service-monitor.yaml` (new file): + +```yaml +apiVersion: azmonitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: %SERVICE_NAME%-monitor + namespace: %KUBERNETES_NAMESPACE% +spec: + selector: + matchLabels: + app: %SERVICE_NAME% + endpoints: + - port: http + path: /metrics + interval: 1m +``` + +Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. + +## After making the change + +- Show the user every file touched. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-public-exposure.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-public-exposure.prompt.md new file mode 100644 index 00000000..d7f6884f --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-public-exposure.prompt.md @@ -0,0 +1,191 @@ +--- +mode: agent +description: Expose a Nano API or Web application publicly - adds HTTPS hosting config, Kubernetes HTTPRoute (Gateway API) resources for ports 80/443, and the CI step that derives the app's public hostname from every configured Azure DNS zone. Use when the user asks to expose a Nano application publicly, add HTTPS/a public domain, or add an HTTPRoute to a Nano API or Web application. +--- + +# Nano add public exposure + +Exposes an existing Nano API or Web application publicly - Kubernetes-internal (`ClusterIP`) +services aren't reachable from outside the cluster by default; this wires the Gateway API +routing, TLS, and DNS pieces needed to reach it at a real public hostname. Read AGENTS.md's +`##### Https` section (under `#### Hosting`) first for the config table; this skill is the +surrounding infrastructure. + +**Ask whether Availability Check should be added too.** Once an app is publicly reachable, +continuous uptime monitoring (`nano-add-availability-check`) becomes possible for the first +time - it specifically requires this. Ask the user up front rather than assuming either way. + +## Before making any change, determine + +1. **Application type.** API or Web only - Console apps have no HTTP surface to expose. Confirm + via `Program.cs`. +2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ + `httproute-443.yaml`. If present, say so and stop. +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see + above. If yes, run `nano-add-availability-check` after this skill completes (it depends on + the hostname/HTTPS wiring this skill adds). +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, + `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target + Azure resource group at deploy time (an app can end up reachable under several zones/domains + at once, not just one). + +## appsettings.json + +Base `appsettings.json`: no change - HTTP stays exposed as-is (`App:Hosting:Http`, unaffected). + +`appsettings.Development.json` - HTTPS is a **local-development-only** concern; `Staging`/ +`Production` TLS terminates at the gateway/cert-manager level, not via this config (AGENTS.md's +own note): + +```json +"App": { + "Hosting": { + "Http": { "UseHttpsRedirection": true }, + "Https": { + "Ports": [4443], + "Certificate": { + "Path": "/root/.dotnet/https/localhost.pfx", + "Password": "password" + }, + "UseHttpsRequired": true + } + } +} +``` + +Avoid port `443` here specifically - AGENTS.md notes it can trigger security warnings inside +Kubernetes; `4443` (or similar) is the established convention. A self-signed +`localhost.pfx`/password pair is needed for the certificate path to resolve locally - check +whether the project already has one (`dotnet dev-certs https` can generate one if not). + +## docker-compose.yml (local Development) + +Map the HTTPS port and certificate volume onto the app's own service: + +```yaml +services: + {service-name}: + ports: + - 4443:4443 + volumes: + - ../:/root/.dotnet/https +``` + +## Kubernetes + +Two new files. `service.yaml` itself needs **no change** - it keeps exposing the plain HTTP port; +the Gateway routes HTTPS traffic to it and terminates TLS itself. + +`.kubernetes/httproute-80.yaml` (redirects HTTP → HTTPS): + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: %SERVICE_NAME%-route-80 + namespace: %KUBERNETES_NAMESPACE% +spec: + parentRefs: + - name: %GATEWAY_NAME% + sectionName: http + hostnames: +%ROUTE_HOST_NAMES% + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +``` + +`.kubernetes/httproute-443.yaml` (the real route to the app): + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: %SERVICE_NAME%-route-443 + namespace: %KUBERNETES_NAMESPACE% +spec: + parentRefs: + - name: %GATEWAY_NAME% + hostnames: +%ROUTE_HOST_NAMES% + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: %SERVICE_NAME% + port: 8080 +``` + +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + +`%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at +deploy time (see below), one hostname line per DNS zone found in the target Azure resource +group, so an app can be reachable under multiple domains without per-domain config. + +## GitHub Actions + +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: + ```yaml + SUB_DOMAIN_NAME: + AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} + ``` +2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is + applied: + ```powershell + $zoneNames = az network dns zone list -g $env:AZURE_GROUP_DNS --query "[].name" -o json | ConvertFrom-Json + + $env:ROUTE_HOST_NAMES = ( + $zoneNames | ForEach-Object { + " - $env:SUB_DOMAIN_NAME.$_" + } + ) -join "`n" + + $env:GATEWAY_NAME = kubectl get gateway -n $env:KUBERNETES_NAMESPACE -o jsonpath='{.items[0].metadata.name}' + ``` +3. Apply `httproute-80.yaml`/`httproute-443.yaml` in `Kubernetes Deploy`, same + `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other manifest. + This assumes a `Gateway` resource already exists in the target namespace - provisioning the + Gateway itself is a one-time, cluster-level concern outside this skill's scope; tell the user + if `kubectl get gateway` would come back empty rather than assuming it's there. + +## After making the change + +- Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to + `nano-add-availability-check` next rather than leaving it unaddressed. +- Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a + related but separate concern worth considering for a publicly-reachable app - this skill + doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-startup-task.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-startup-task.prompt.md new file mode 100644 index 00000000..a5bb19e6 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-startup-task.prompt.md @@ -0,0 +1,67 @@ +--- +mode: agent +description: Add a Startup Task to a Nano application - a class deriving BaseStartupTask that runs one-time initialization before the app accepts traffic (API/Web) or workers start (Console). Use when the user asks to add cache warm-up, a startup check, or one-time initialization to a Nano API, Web, or Console application. +--- + +# Nano add startup task + +Adds a Startup Task to an existing Nano API, Web, or Console application. Read AGENTS.md's +`### Start-Up Tasks` section first - it documents execution/readiness semantics in full; this +skill is just the file shape. Not the same mechanism as Nano's built-in data-provider migration +task - this is for your own one-time initialization work. + +## Before making any change, determine + +1. **Name and job.** Ask if not already given - what needs to happen once before the app is + considered ready (cache warm-up, an external dependency check, etc.). +2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. +3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on + it for anything tied to actual application shutdown. + +## Startup task class + +`Startup/{Name}StartupTask.cs` in the application project (conventional location, not enforced - +discovered by type): + +```csharp +public class MyStartupTask(ILogger logger) : BaseStartupTask(logger) +{ + public override async Task OnStartAsync(CancellationToken cancellationToken = default) + { + // one-time init - cache warm-up, external dependency check, etc. + } + + // optional - only override if needed; see the timing note below + public override async Task OnStopAsync(CancellationToken cancellationToken = default) + { + // cleanup for what OnStartAsync acquired - runs right after OnStartAsync completes, + // NOT at real application shutdown + } +} +``` + +No registration needed - every non-abstract `IStartupTask` in the entry assembly is discovered +and registered `Scoped` automatically. Any other registered service, including scoped ones, can +be injected into the constructor. + +⚠ **`OnStopAsync` is not "runs at application shutdown."** It fires immediately after every +task's `OnStartAsync` completes, as a completion/cleanup hook - not tied to real shutdown timing +(the host's real shutdown sequence may invoke it again, but that's incidental, not its purpose). +Only override it for cleanup that belongs right after this task's own startup work. + +**Execution**: all registered tasks' `OnStartAsync` run **concurrently** (`Task.WhenAll`), in one +shared scope, before the app accepts requests (API/Web) or any Console Worker starts. If [Health +Checks](nano-add-health-checks) are enabled, the app isn't reported ready until every task's +`OnStartAsync` **and** `OnStopAsync` have completed - this readiness gate applies automatically, +nothing further to wire for it. + +## After making the change + +- Show the user the file added. +- Restate step 2's consequence plainly: an unhandled exception here takes the whole app down at + startup - make sure that's the behavior actually wanted for this specific task before finishing. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-storage-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-storage-provider.prompt.md new file mode 100644 index 00000000..15048379 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-storage-provider.prompt.md @@ -0,0 +1,330 @@ +--- +mode: agent +description: Add a Nano storage provider (Local or Azure) to a Nano.Library-based application - registers it in Program.cs, adds the Storage configuration section, the local docker-compose volume mount, and the Kubernetes persistent volume (plus, for Azure, the Staging/Production fileshare-provisioning CI step). Use when the user asks to add file storage, a fileshare, or a specific storage provider to a Nano API, Web, or Console application. +--- + +# Nano add storage provider + +Wires a Nano storage provider into an existing Nano API, Web, or Console application. Read +`AGENTS.md` in the target repo root first - its `## Nano.Storage` section documents the +`Configuration` table, the provider/package table, and `IPathProvider` in full; this skill does +not repeat any of that, only how to apply it and wire the surrounding infrastructure +(docker-compose, K8s, and for Azure, CI) without breaking what's already there. + +Both providers are simpler at the code level than a data or eventing provider - per AGENTS.md, +`Local` and `Azure` both represent storage already mounted into the container's filesystem and +are accessed identically through `IPathProvider`; there's no provider-specific client/SDK to +wire into the app itself. **Everything that differs between them is infrastructure** - +docker-compose is identical either way; only the Kubernetes manifests and (for Azure) the CI +provisioning step differ. + +## Before making any change, determine + +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type + names). Ask the user if not already given. +3. **Is a storage provider already registered?** Check `Program.cs` for an existing + `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation + per app, not a multi-provider case. If one exists, treat this as a replace and say so. +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for + `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the + package step. Otherwise add `` + to the **application project**, matching the version of the project's existing Nano + application-type package. Never a `ProjectReference` to Nano.Library source. + +## Program.cs + +```csharp +using Nano.Storage.Extensions; +using Nano.Storage.; +``` + +```csharp +x.AddNanoStorage<Provider>(); +``` + +`Provider` is `LocalFileShareProvider` for `Local`, `AzureFileshareProvider` for +`Azure` (see AGENTS.md's provider table for the exact names). Same `.ConfigureServices(...)` +lambda placement and `_` → `x` rename rule as the other add-provider skills. No other C# files +are needed - no context/factory equivalent, unlike the data-provider skill. + +## appsettings.json + +Add the `Storage` section from AGENTS.md's `### Configuration` example to the base +`appsettings.json` (sibling of `App`). `ShareName` isn't sensitive (it's just a name, not a +credential) - it can stay set in the base file for both providers, no Development-specific +override needed for it. + +Include `HealthCheck` only if `App:HealthCheck` is also enabled - AGENTS.md is explicit that +storage health checks do nothing without it (⚠ under `#### Health Checks`), so adding one +without the other is dead configuration. + +## docker-compose.yml (local Development) + +Identical for both providers - a bind-mounted local directory standing in for whatever the real +provider mounts in Staging/Production. Add to the app's own service in +`.docker/docker-compose.yml`: + +```yaml +volumes: + - ./bin/:/mnt/ +``` + +matching `Storage:ShareName`. No separate service container needed (unlike a data or eventing +provider) - there's nothing to run, just a directory. + +## Kubernetes - Local + +- **`.kubernetes/storage-storageclass.yaml`** (new file): + ```yaml + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: %SERVICE_NAME%-storage-class + provisioner: disk.csi.azure.com + parameters: + storageaccounttype: Standard_LRS + kind: Managed + reclaimPolicy: Retain + volumeBindingMode: WaitForFirstConsumer + ``` +- **`ReadWriteOnce`, one volume per pod, not one shared volume.** A local disk-backed volume can + only attach to a single pod - so if the app runs more than one replica (`deployment.yaml`'s + `kind: Deployment`, all replicas sharing one pod template), every replica referencing the same + static PVC name would race for the same single-attach disk; only the first pod to schedule + would mount successfully; the rest fail with a `Multi-Attach` error and never become ready. So + `Local` storage's Deployment must be a **`StatefulSet`**, using `volumeClaimTemplates` instead + of a single static `PersistentVolumeClaim` file - that gives each replica pod its own + separate, uniquely-named PVC/disk automatically. (This does mean each pod's files are + isolated from the others - a file written via one replica isn't visible from another. If the + app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, + below - its file-share CSI mount supports concurrent multi-pod access.) +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` + `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a + provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) + directory") - include `tmp` for **both** providers, it's provider-agnostic: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/%STORAGE_SHARE_NAME% + - name: tmp + mountPath: /tmp + ``` + ```yaml + volumes: + - name: tmp + emptyDir: {} + ``` + and, as a top-level sibling of `template:` (not nested inside `template.spec`) - + `volumeClaimTemplates` replaces the `PersistentVolumeClaim` file entirely: + ```yaml + volumeClaimTemplates: + - metadata: + name: %SERVICE_NAME%-volume + spec: + accessModes: + - ReadWriteOnce + storageClassName: %SERVICE_NAME%-storage-class + resources: + requests: + storage: %STORAGE_SIZE%Gi + ``` +- **`.kubernetes/service-headless.yaml`** (new file) - a `StatefulSet` requires a governing + headless service for pod network identity, separate from the app's normal `ClusterIP` service: + ```yaml + apiVersion: v1 + kind: Service + metadata: + name: %SERVICE_NAME%-stateful-headless + namespace: %KUBERNETES_NAMESPACE% + spec: + clusterIP: None + ports: + - name: http + port: 8080 + selector: + app: %SERVICE_NAME% + ``` +- **`.kubernetes/autoscaler.yaml`** - always present on an API/Web app (per AGENTS.md's Solution + Structure), the only app types this `StatefulSet` conversion ever applies to: change its + `scaleTargetRef.kind` from `Deployment` to `StatefulSet` too - otherwise it silently targets a + resource kind that no longer exists. +- **Workflow**: add `STORAGE_SIZE` (a bare number of GB, e.g. `1000` - the template above appends + `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply + `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) + and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no + separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. + +## Kubernetes - Azure + +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. + +1. **Workflow env vars**: + ```yaml + AZURE_GROUP_STORAGE: ${{ vars.AZURE_RESOURCE_GROUP_STORAGE }} + AZURE_GROUP_BACKUP: ${{ vars.AZURE_RESOURCE_GROUP_BACKUP }} + STORAGE_SIZE: 25 + STORAGE_SHARE_NAME: + ``` +2. **Fileshare provisioning** - two steps, placed after `Managed Identity` and before + `Kubernetes Deploy`. Both are idempotent (existence-checked), safe to always include: + ```yaml + - name: Storage Role Permissions + shell: pwsh + run: | + $env:STORAGE_ACCOUNT_ID = az storage account list -g $env:AZURE_GROUP_STORAGE --query [0].id -o tsv; + + az role assignment create ` + --assignee-object-id $env:IDENTITY_PRINCIPAL_ID ` + --assignee-principal-type ServicePrincipal ` + --role "Storage File Data SMB MI Admin" ` + --scope $env:STORAGE_ACCOUNT_ID + + if ($LastExitCode -ne 0) { throw "error"; }; + + - name: Create Fileshare + shell: pwsh + run: | + $env:STORAGE_ACCOUNT_NAME = az storage account list -g $env:AZURE_GROUP_STORAGE --query [0].name -o tsv; + + $env:FILE_SHARE_EXISTS = az storage share-rm exists -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --query exists; + + if ($env:FILE_SHARE_EXISTS -eq "false") + { + az storage share-rm create -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --access-tier TransactionOptimized --quota $env:STORAGE_SIZE; + } + else + { + az storage share-rm update -g $env:AZURE_GROUP_STORAGE -n $env:STORAGE_SHARE_NAME --storage-account $env:STORAGE_ACCOUNT_NAME --access-tier TransactionOptimized --quota $env:STORAGE_SIZE; + } + + if ($LastExitCode -ne 0) { throw "error"; }; + + $env:BACKUP_VAULT_NAME = az backup vault list -g $env:AZURE_GROUP_BACKUP --query [0].name -o tsv; + + az backup protection enable-for-azurefileshare -g $env:AZURE_GROUP_BACKUP -v $env:BACKUP_VAULT_NAME -p $env:STORAGE_ACCOUNT_NAME-fileshare-backup-policy --storage-account $env:STORAGE_ACCOUNT_NAME --azure-file-share $env:STORAGE_SHARE_NAME; + + if ($LastExitCode -ne 0) { throw "error"; }; + + echo "STORAGE_ACCOUNT_NAME=$env:STORAGE_ACCOUNT_NAME" >> $env:GITHUB_ENV; + ``` +3. **`.kubernetes/storage-pv.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: PersistentVolume + metadata: + name: %SERVICE_NAME%-azurefile-pv-%VOLUME_NAME_SUFFIX% + spec: + capacity: + storage: %STORAGE_SIZE%Gi + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: azurefile-static + mountOptions: + - dir_mode=0777 + - file_mode=0777 + - uid=0 + - gid=0 + claimRef: + name: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + namespace: %KUBERNETES_NAMESPACE% + csi: + driver: file.csi.azure.com + volumeHandle: %AZURE_GROUP_STORAGE%#%STORAGE_ACCOUNT_NAME%#%STORAGE_SHARE_NAME%-%VOLUME_NAME_SUFFIX% + volumeAttributes: + shareName: %STORAGE_SHARE_NAME% + storageAccount: %STORAGE_ACCOUNT_NAME% + resourceGroup: %AZURE_GROUP_STORAGE% + clientID: %IDENTITY_CLIENT_ID% + mountWithWorkloadIdentityToken: "true" + ``` +4. **`.kubernetes/storage-pvc.yaml`** (new file): + ```yaml + apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + namespace: %KUBERNETES_NAMESPACE% + spec: + accessModes: + - ReadWriteMany + storageClassName: azurefile-static + resources: + requests: + storage: %STORAGE_SIZE%Gi + volumeName: %SERVICE_NAME%-azurefile-pv-%VOLUME_NAME_SUFFIX% + ``` +5. **`%VOLUME_NAME_SUFFIX%`** - derived in the `Kubernetes Deploy` step, not a static env var: + ```powershell + $env:VOLUME_NAME_SUFFIX = $env:IDENTITY_CLIENT_ID.Substring(0, 5); + ``` + placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before + `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. +6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share + it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: + ```yaml + volumeMounts: + - name: %SERVICE_NAME%-volume + mountPath: /mnt/%STORAGE_SHARE_NAME% + - name: tmp + mountPath: /tmp + ``` + ```yaml + volumes: + - name: %SERVICE_NAME%-volume + persistentVolumeClaim: + claimName: %SERVICE_NAME%-azurefile-pvc-%VOLUME_NAME_SUFFIX% + - name: tmp + emptyDir: {} + ``` + +## After making the change + +- Show the user every file touched, grouped by concern (app code, local docker-compose, + Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. +- If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client-configuration.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..cd886671 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,91 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client.prompt.md new file mode 100644 index 00000000..b9338901 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-api-client.prompt.md @@ -0,0 +1,80 @@ +--- +mode: agent +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. +--- + +# Nano remove API client + +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. + +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. + +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. + +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. + +## Before making any change, determine + +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. + +## Client class + +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). + +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. + +## After making the change + +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-apikey.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-apikey.prompt.md new file mode 100644 index 00000000..7ec1d5fb --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -0,0 +1,69 @@ +--- +mode: agent +description: Remove Nano's built-in API-key authentication (Data:Identity:ApiKey) from a Nano.Library-based application - unregisters the ApiKey configuration and removes its Staging/Production secret/CI wiring. Use when the user asks to remove API-key authentication or the X-Api-Key header scheme from a Nano API or Web application - not for removing JWT authentication by itself, that's nano-remove-authentication-jwt. +--- + +# Nano remove API-key authentication + +Fully removes Nano's API-key authentication from an existing Nano API/Web application - the +counterpart to `nano-add-authentication-apikey`. Read that skill first - this one undoes exactly +what it adds. + +## Before making any change, determine + +1. **Is API-key authentication currently configured?** Check the base `appsettings.json` for + `Data:Identity:ApiKey:Secret`. If not present, say so and stop. +2. **Is JWT authentication also configured on this app** (`App:Authentication:Jwt`/an existing + `AuthController`)? This determines what removal actually does - surface it before proceeding: + - **Also configured**: nothing dramatic - `AuthController` doesn't depend on `ApiKeyOptions` + at all, so it keeps working exactly as before. `/auth/login/apikey` simply becomes hidden + again (`ConditionalActionsConvention` gates its visibility purely on + `Data:Identity:ApiKey:Secret`), and the scheme reverts from `JWT_OR_APIKEY` to JWT-only. No + file besides config/K8s/CI needs touching. + - **Not configured (pure API-key mode)**: this was the app's **only** authentication scheme - + removing it leaves the app with no authentication at all, every endpoint anonymous by + default (AGENTS.md). Confirm this is intended before proceeding; it's a security-relevant + change, not just a config cleanup, and there's no controller here to hint at it either (pure + API-key mode never had one). + +## appsettings.json + +Remove `Data:Identity:ApiKey:Secret` from the base `appsettings.json`, and from +`appsettings.Development.json` too if a local convenience value was set there (per +`nano-add-authentication-apikey`'s note that this is the one place a Development override might +exist, unlike the shared JWT key pair). + +## Kubernetes / GitHub Actions + +Unlike `auth-jwt-secret`, this secret is always per-app (never shared across services), so +there's no issuer/validator distinction to worry about here - always safe to remove: + +- Delete `.kubernetes/auth-api-key-secret.yaml`. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `AUTH_API_KEY_SECRET` workflow env var. +- Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s + container `env`. + +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + +## After making the change + +- Show the user every file touched/deleted. +- Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange + endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - + whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-jwt.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-jwt.prompt.md new file mode 100644 index 00000000..273b0be6 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -0,0 +1,148 @@ +--- +mode: agent +description: Remove Nano's built-in JWT authentication (App:Authentication:Jwt) from a Nano.Library-based application - unregisters the Jwt configuration across every environment, deletes the AuthController, and removes the Staging/Production key secret/CI wiring. Use when the user asks to remove login, sign-in, or JWT authentication from a Nano API or Web application - not for removing API-key authentication by itself, that's nano-remove-authentication-apikey. +--- + +# Nano remove JWT authentication + +Fully removes Nano's JWT authentication from an existing Nano API/Web application - the +counterpart to `nano-add-authentication-jwt`. Read that skill first - this one undoes exactly +what it adds. + +**The `AuthController` cannot survive this removal, ever - not a judgment call.** +`BaseAuthController`'s constructor requires `IAuthRepository` as a non-nullable parameter, and +`IAuthRepository` is only registered when `Jwt != null` (`AddNanoAuthentication`). Once `Jwt` is +gone, the controller fails DI resolution at startup if left in place. Delete it unconditionally, +even if API-key auth stays configured afterward - see step 3. + +## Before making any change, determine + +1. **Is JWT authentication currently configured?** Check the base `appsettings.json` for + `App:Authentication:Jwt`, or an existing `AuthController`. If neither exists, say so and stop. +2. **Is this app the token issuer or a validator-only app?** Check `.kubernetes/deployment.yaml` + for whether it maps `App__Authentication__Jwt__PrivateKey` (issuer) or `PublicKey` only + (validator) - this determines which Kubernetes/CI cleanup applies below. +3. **Is API-key authentication also configured** (`Data:Identity:ApiKey:Secret` set)? This + changes what removing `Jwt` actually does to the app, and needs surfacing before proceeding: + - **Not configured**: this app ends up with no authentication at all - every endpoint becomes + anonymous by default (AGENTS.md: "If no authentication schemes has been configured, all + endpoints will be accessible anonymously"). Confirm this is intended before proceeding; it's + a real security-relevant change, not just a code cleanup. + - **Also configured**: the app doesn't lose authentication - it reverts to **pure API-key + mode** (per `nano-add-authentication-apikey`), since `ApiKeyAuthenticationHandler` doesn't + depend on `Jwt` at all. The `AuthController` still gets deleted (per the note above), and + `/auth/login/apikey` disappears with it - API-key callers keep working unchanged, but lose + the "trade a key for a JWT once" convenience. Tell the user this explicitly; don't let it + read as a side effect they weren't told about. +4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just + the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added + custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. + +## appsettings.json + +Remove `App:Authentication:Jwt` (the whole object, including `RootLogin`/`ExternalLogins` if +present) from the base `appsettings.json`, `appsettings.Development.json`, +`appsettings.Staging.json`, and `appsettings.Production.json` - every environment file that has +it, per `nano-add-authentication-jwt`'s placement (`Issuer`/`Audience` overrides live in every +environment file, not just Development). + +## AuthController + +Delete `Controllers/AuthController.cs` - see the note at the top; this isn't conditional. + +## Kubernetes / GitHub Actions - issuer app only + +If step 2 found this app is the issuer: + +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. +- If this app was the **only** issuer in the solution, every validator-only app that references + `auth-jwt-secret` now points at a secret nothing creates anymore - flag this to the user + explicitly; it's outside this skill's scope (a different app's files), but silently leaving it + broken elsewhere is worse than mentioning it. + +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + +## Kubernetes - every app (issuer and validator) + +Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries +(whichever are present - a validator only ever has `PublicKey`) from +`.kubernetes/deployment.yaml`'s container `env`. + +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + +## After making the change + +- Show the user every file touched/deleted. +- Restate step 3's outcome one more time now that it's actually done - either "this app now has + no authentication, every endpoint is anonymous" or "this app is now in pure API-key mode, the + JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, + explicit confirmation rather than folding into a file list. +- If step 2 found this app was the sole issuer, restate the warning about now-broken + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) 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 `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-availability-check.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-availability-check.prompt.md new file mode 100644 index 00000000..f69e3d5a --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-availability-check.prompt.md @@ -0,0 +1,34 @@ +--- +mode: agent +description: Remove availability monitoring from a Nano API or Web application - removes the CI step that creates/maintains the Azure Application Insights ping test and alert. Use when the user asks to remove availability monitoring, an uptime check, or a ping test from a Nano application. +--- + +# Nano remove availability check + +Removes availability monitoring from an existing Nano application - the counterpart to +`nano-add-availability-check`. + +## Before making any change, determine + +1. **Is Availability Check currently configured?** Check the workflow for an "Add Availability + Check" step. If absent, say so and stop. + +## GitHub Actions + +Remove the "Add Availability Check" step entirely, and `AZURE_GROUP_LOGS` if nothing else in the +workflow still references it. + +⚠ This does **not** delete the underlying Azure resources (the Application Insights web test and +its metric alert) - those are real Azure resources this step only creates/maintains +idempotently, it never owned their lifecycle for deletion. Removing the workflow step just stops +maintaining them going forward; the ping test keeps running (and could keep alerting) until +someone deletes it directly in Azure (`az monitor app-insights web-test delete` / removing the +alert resource). Say this explicitly rather than implying the monitoring stops the moment the +workflow step is removed. + +## After making the change + +- Show the user the workflow change. +- Restate the ⚠ above - the Azure-side resources need manual cleanup if the user actually wants + the monitoring (and its alerts) to stop, not just future deploys to skip maintaining it. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-azure-managed-identity.prompt.md new file mode 100644 index 00000000..163b27bf --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Azure Managed Identity / Kubernetes Workload Identity wiring from a Nano.Library-based application - deletes service-account.yaml, the workload-identity pod annotations, and the CI "Managed Identity" step. Use when the user asks to remove Managed Identity or Workload Identity from a Nano API, Web, or Console application - checks first whether a Data or Storage provider still depends on it. +--- + +# Nano remove managed identity + +Fully removes Azure Managed Identity / Kubernetes Workload Identity wiring from an existing Nano +API, Web, or Console application - the counterpart to `nano-add-azure-managed-identity`. Read that +skill first - this one undoes exactly what it adds. + +## Before making any change, determine + +1. **Is Managed Identity currently wired?** Check for `.kubernetes/service-account.yaml` and the + `Managed Identity` workflow step. If neither exists, say so and stop. +2. **What depends on it?** Two genuinely different situations - check both, and don't treat them + the same: + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. + - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). + Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's + `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's + file-share CSI mount is unconditionally Workload-Identity-authenticated + (`mountWithWorkloadIdentityToken: "true"`). If Azure storage is in use, Managed Identity + **cannot** be removed without breaking it outright - the only ways forward are switching to + `Local` storage (`nano-remove-storage-provider` then `nano-add-storage-provider` with + `Local`) or removing storage entirely first. Tell the user this plainly and stop; don't + proceed with Managed Identity removal while Azure storage still depends on it. + If neither applies, proceed normally. + +## Kubernetes + +- Delete `.kubernetes/service-account.yaml`. +- Remove the `azure.workload.identity/use: "true"` label and `serviceAccountName` field from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s pod template. +- Remove the `service-account.yaml` apply block from the `Kubernetes Deploy` workflow step. + +## GitHub Actions + +Remove the `Managed Identity` step entirely. Leave `AZURE_GROUP_KUBERNETES` alone - every app's +workflow needs it independently for basic AKS deploy (`az aks get-credentials`), regardless of +Managed Identity. + +⚠ This does **not** delete the underlying Azure user-assigned identity or its federated +credential in Azure itself (`az identity delete`) - that's a real Azure resource this skill +doesn't provision or own the lifecycle of. Removing the workflow step just stops maintaining it +going forward; say so explicitly rather than implying the Azure-side resource is gone too. + +## After making the change + +- Show the user every file touched/deleted. +- Restate whatever was flagged in step 2 - the Data provider reverted to `Credentials` (and what + that required), or the fact that Storage blocked the whole removal - one more time here. +- If step 1 or step 2's Storage case stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-console-worker.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-console-worker.prompt.md new file mode 100644 index 00000000..8785b630 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-console-worker.prompt.md @@ -0,0 +1,28 @@ +--- +mode: agent +description: Remove a Console Worker from a Nano Console application - deletes the BaseWorker-derived class. Use when the user asks to remove a worker, background job, or a specific task from a Nano Console application. +--- + +# Nano remove console worker + +Removes a Console Worker from an existing Nano Console application - the counterpart to +`nano-add-console-worker`. + +## Before making any change, determine + +1. **Which worker?** Confirm the class name/file if the project has more than one - check + `Workers/` (or search for `BaseWorker`/`IWorker` if not in the conventional location). +2. **Is this the last worker in the app?** Not a blocker - a Console app with zero workers still + runs (Startup Tasks, if any, still execute), it just does nothing beyond that. Worth + mentioning if it leaves the app with no actual job. + +## Worker class + +Delete the file. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change. + +## After making the change + +- Show the user the file removed. +- If step 2 applies (last worker removed), say so explicitly - the app will still start and run + its Startup Tasks, but do nothing further. 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 new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-data-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-data-provider.prompt.md new file mode 100644 index 00000000..91df5c29 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-data-provider.prompt.md @@ -0,0 +1,142 @@ +--- +mode: agent +description: Remove a Nano data provider (MySql, PostgreSQL, SqlServer, SqLite, or InMemory) from a Nano.Library-based application - unregisters it in Program.cs and removes the DbContext/DbContextFactory, Data configuration, docker-compose database service, and (for MySql/PostgreSQL/SqlServer) the Staging/Production migration CI step and Kubernetes secret, or (for SqLite) the persistent volume. Use when the user asks to remove a database, drop persistence, or strip a data provider out of a Nano API, Web, or Console application. +--- + +# Nano remove data provider + +Fully removes a Nano data provider from an existing Nano API, Web, or Console application - the +counterpart to `nano-add-data-provider`. Read that skill first - this one undoes exactly what it +adds, file for file; refer back to it for the shapes/locations of anything unclear here rather +than re-deriving them. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoData()`. If none, say so and stop. +2. **What depends on it?** This is the step most worth getting right - removing a Data provider + out from under dependent features leaves them broken, not just unused. As with eventing, the + real risk here is a startup crash, not just dead code - `IRepository` and the concrete + `DbContext` are both registered by `AddNanoData()` (AGENTS.md's + `### Repositories` section), and any class with either as a **required** constructor + parameter fails DI resolution the instant the provider is gone: + - **`IRepository` or the `DbContext` injected directly.** Search the project for both, + anywhere - controllers, services, workers. A scaffolded controller + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, + so any existing entity's controller is a guaranteed hit - the app won't start at all with + it left in place and the provider gone. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. + - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) + or a JWT auth setup depends on the Identity store this context provides, removing the + provider breaks authentication entirely. + If any of these apply, tell the user exactly what removing the provider will do (crash vs. + dead code) and confirm before proceeding - don't remove out from under them without saying so. +3. **Is the package reference this skill's to remove?** Same check as the logging-remove skill: + if the project references `NanoCore`/`Nano.All`, leave it alone (it covers unrelated + features too). Otherwise remove the `Nano.Data.` `PackageReference` from the + application project. + +## Program.cs + +Remove the `using Nano.Data.Extensions;`, `using Nano.Data.;`, and `using +.Data;` lines, and the `.AddNanoData<...>()` call. Same empty-lambda cleanup rule as +the logging-remove skill: if nothing else is left in `.ConfigureServices(...)`, restore the +blank-app placeholder and the `_` discard parameter. + +## Files to delete + +- `Data/DbContext.cs` +- `Data/DbContextFactory.cs` (if present - `InMemory` never had one) +- `Migrations/` folder (if present - dead without the factory that constructs the context for + `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. + +## appsettings.json + +Remove the `Data` section entirely from the base `appsettings.json`, and from +`appsettings.Development.json` if it has its own `Data` override there (the common case - see +`nano-add-data-provider`'s placement rules for what that override normally contains). + +**Exception - SqLite**: its `Data` section lives entirely in the base file (no Development +override, per `nano-add-data-provider`'s SqLite section) - remove it from there instead. + +## docker-compose.yml + +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. + +## SqLite-specific cleanup + +If the provider was `SqLite`, additionally: +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the + `Kubernetes Deploy` workflow step. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. +- Remove the `SQL_SIZE` workflow env var, if nothing else uses it. + +## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) + +Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). + +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` doesn't add one). Only remove + `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). +3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block + from the `Kubernetes Deploy` step. +4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from + `.kubernetes/configmap.yaml`. +5. **Deployment** - remove the `Data__ConnectionString` `secretKeyRef` entry from + `.kubernetes/deployment.yaml`'s container `env`. + +## After making the change + +- Show the user every file touched/deleted, grouped by concern (app code, local docker-compose, + Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list + to be easy to sanity-check. +- Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ + Production section never existed to begin with, say so explicitly. 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 new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-event-handler.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-eventing-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-eventing-provider.prompt.md new file mode 100644 index 00000000..61e6f9b5 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-eventing-provider.prompt.md @@ -0,0 +1,80 @@ +--- +mode: agent +description: Remove a Nano eventing provider (currently only RabbitMq) from a Nano.Library-based application - unregisters it in Program.cs and removes the Eventing configuration, local docker-compose broker service, and Kubernetes secret reference. Use when the user asks to remove eventing, pub/sub messaging, or a message broker from a Nano API, Web, or Console application. +--- + +# Nano remove eventing provider + +Fully removes a Nano eventing provider from an existing Nano API, Web, or Console application - +the counterpart to `nano-add-eventing-provider`. Read that skill first - this one undoes exactly +what it adds. + +## Before making any change, determine + +1. **Is an eventing provider currently registered?** Check `Program.cs` for + `.AddNanoEventing<...>()`. If none, say so and stop. +2. **What depends on it?** Two distinct risks, different severities - check for both: + - **Startup crash.** Search for `IEventing` used as a constructor parameter (injected via DI) + anywhere in the project, and check whether it's required or optional (`IEventing? + eventing`, the pattern the entity-scaffold skill uses when an eventing provider isn't + registered). A class with a **required** `IEventing` parameter fails DI resolution the + moment the provider is gone - the app won't start at all, not even a runtime error deep in + some request path. This is the more urgent of the two checks. + - **Silent no-op.** Per AGENTS.md's `### Entity Events` section, `[Publish]`/`[Subscribe]` + entity replication **requires Eventing configured, and silently does nothing without it** - + no exception, no error, it just stops syncing. Also check for any class deriving + `BaseEventHandler` (general pub/sub, not entity events) - its handler simply never + fires again, with no signal that it stopped. + If either exists, tell the user exactly what removing the provider will do to it (crash vs. + silent no-op) and confirm before proceeding - don't remove out from under them without saying + so. +3. **Is the package reference this skill's to remove?** Same check as the other remove skills: + leave `NanoCore`/`Nano.All` alone if present; otherwise remove the `Nano.Eventing.RabbitMq` + `PackageReference` from the application project. + +## Program.cs + +Remove `using Nano.Eventing.Extensions;`, `using Nano.Eventing.RabbitMq;`, and the +`.AddNanoEventing<...>()` call. Same empty-lambda cleanup as the other remove-provider skills: +restore the blank-app placeholder and `_` parameter if nothing else is left in +`.ConfigureServices(...)`. + +## appsettings.json + +Remove the `Eventing` section from the base `appsettings.json`, and its `Credentials` override +from `appsettings.Development.json` if present (per `nano-add-eventing-provider`'s placement - +only `Credentials` lives in the Development file, the rest of the section is base-only). + +## docker-compose.yml + +Remove the `eventing` service from `.docker/docker-compose.yml` entirely (delete, don't +comment out - unlike the data-provider skill's multiple-alternatives convention, there's only +one eventing provider, so there's no sibling variant worth preserving as a reference). Also +remove it from the app's own service's `depends_on` list. + +## Existing entity controllers + +The counterpart to `nano-add-eventing-provider`'s retrofit step: remove the `IEventing? eventing` +constructor parameter (and the corresponding base-constructor argument) from every entity +controller that has one, across the full `BaseEntity*Controller`/`BaseEntityUserController` +hierarchy - it's dead weight once nothing can ever populate it. This is separate from, and safe +regardless of, the crash risk already flagged in step 2: a controller with the **nullable** +`IEventing?` form just loses an unused parameter here; one with a **required** `IEventing` +parameter (the crash case) still needs that constructor fixed by hand as part of addressing step +2 - removing the parameter here is what actually resolves it, once the user has confirmed that's +acceptable. + +## Kubernetes + +Remove the four `Eventing__*` env entries (`Eventing__Host`, `Eventing__Port`, +`Eventing__Credentials__Id`, `Eventing__Credentials__Secret`) from +`.kubernetes/deployment.yaml`'s container `env`. There's no secret file to delete - the +`rabbitmq-default-user` secret is shared/cluster-wide and outlives this app regardless. + +## After making the change + +- Show the user every file touched, including every controller that had `IEventing? eventing` + removed, and restate anything flagged in step 2 - required `IEventing` injections that will now + crash the app, plus any orphaned `[Publish]`/`[Subscribe]` entities or dead event handlers - one + more time now that the removal is actually done, not just as the earlier confirmation. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, say so explicitly. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-health-checks.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-health-checks.prompt.md new file mode 100644 index 00000000..0c68d6a5 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-health-checks.prompt.md @@ -0,0 +1,41 @@ +--- +mode: agent +description: Remove Nano's built-in health checks (App:HealthCheck) from a Nano API or Web application - removes the config and the Kubernetes liveness/readiness probes together, since leaving one without the other breaks the pod. Use when the user asks to remove health checks or the /healthz endpoint from a Nano API or Web application. +--- + +# Nano remove health checks + +Removes Nano's `/healthz` endpoint from an existing Nano API or Web application - the counterpart +to `nano-add-health-checks`. Read that skill first - this one undoes exactly what it adds, and +the same "never do one half without the other" rule applies in reverse here. + +## Before making any change, determine + +1. **Is `App:HealthCheck` currently configured?** Check the base `appsettings.json`. If absent, + say so and stop. +2. **What depends on it?** + - **Kubernetes probes will start failing if left behind.** Removing `App:HealthCheck` without + also removing the `livenessProbe`/`readinessProbe` in `deployment.yaml`/`stateful-set.yaml` + means Kubernetes keeps probing a `/healthz` path that no longer exists - the pod gets marked + unhealthy and crash-loops. Both must be removed together; this is not optional cleanup. + - **Provider health checks go dark, not broken.** If `Data`/`Eventing`/`Storage`/ + `App:Apis:{Client}`'s own `HealthCheck` blocks are configured, they become dead config once + `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a + crash, but tell the user - leaving those blocks in place with no effect is confusing without + an explanation. + +## Kubernetes + +Remove the `livenessProbe` and `readinessProbe` entries from `.kubernetes/deployment.yaml`'s (or +`stateful-set.yaml`'s) container spec. + +## appsettings.json + +Remove `App:HealthCheck` from the base `appsettings.json`. + +## After making the change + +- Show the user every file touched. +- Restate step 2's provider-health-check note if applicable - which blocks are now dead config, + without functional effect. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-identity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-identity.prompt.md new file mode 100644 index 00000000..228f3ae5 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-identity.prompt.md @@ -0,0 +1,124 @@ +--- +mode: agent +description: Remove Nano's persistent Identity store (Data:Identity) from a Nano.Library-based application - unregisters the Identity configuration and deletes the User entity/mapping/controller triplet Nano's identity actions attached to. Use when the user asks to remove user accounts, the user store, or persistent identity from a Nano API, Web, or Console application - not for removing authentication/login itself, that's nano-remove-authentication-jwt/nano-remove-authentication-apikey. +--- + +# Nano remove identity + +Fully removes Nano's persistent Identity store from an existing Nano API, Web, or Console +application - the counterpart to `nano-add-identity`. Read that skill first - this one undoes +exactly what it adds. + +## Before making any change, determine + +1. **Is Identity currently configured?** Check the base `appsettings.json` for `Data:Identity`, + and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. If neither + exists, say so and stop. +2. **What depends on it?** Two distinct risks, different severities - check for both: + - **Startup crash.** Search for `IIdentityRepository`/`IIdentityRepository` used as + a **required** constructor parameter anywhere in the project. This is a guaranteed hit, not + a maybe: the identity entity's own controller (`nano-add-identity`'s own template) always + takes it as a required parameter, so that controller crashes DI resolution the instant + Identity is gone - the app won't start at all, same class of failure as the Data-provider + and Eventing removal skills' crash checks. + - **Authentication degrades, doesn't crash - but is worth flagging just as clearly.** If + `App:Authentication:Jwt` is configured on this app (`nano-add-authentication-jwt`), removing + Identity silently drops `AuthIdentityRepository` back to `null` (AGENTS.md's sub-repository + table: populated only when Identity is configured) - `/auth/login`, `/auth/login/refresh`, + and `/auth/logout` stop being registered, no exception, they just disappear. If + `Data:Identity:ApiKey:Secret` is configured (`nano-add-authentication-apikey`), it's removed + along with the rest of `Data:Identity` (it's a child of it) - API-key auth disappears + entirely, including `/auth/login/apikey` if that was in use. Neither of these crashes the + app, but both are significant behavior changes on an app that may have real callers - this + skill does not touch `App:Authentication` itself, so if the user also wants Authentication + removed, point them at `nano-remove-authentication-jwt`/`nano-remove-authentication-apikey` + rather than leaving it half-configured and pointing at nothing. + If either applies, tell the user exactly what removing Identity will do (crash vs. silent + endpoint loss) and confirm before proceeding - don't remove out from under them without saying + so. +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate + NuGet package, so there's nothing to remove from the `.csproj` here either. + +## appsettings.json + +Remove the `Data:Identity` section entirely from the base `appsettings.json`. There's no +`appsettings.Development.json` override to also clean up - per `nano-add-identity`, the whole +section lives in the base file only. + +## User entity, mapping, and controller + +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: + +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: +- `Data/.cs` (or the `.Models` project in a split layout). +- `Data/Mappings/Mapping.cs`. +- `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). +- `Controllers/sController.cs` (API/Web only). + +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. + +## After making the change + +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. +- Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific + Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more + time here, even if the user already confirmed it. +- If the user also wants Authentication removed, say explicitly that this skill didn't touch it + and point them at `nano-remove-authentication-jwt`/`nano-remove-authentication-apikey`. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-logging-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-logging-provider.prompt.md new file mode 100644 index 00000000..18a5cfba --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-logging-provider.prompt.md @@ -0,0 +1,54 @@ +--- +mode: agent +description: Remove a Nano logging provider (Log4Net, Microsoft, NLog, or Serilog) from a Nano.Library-based application - unregisters it in Program.cs and removes the Logging configuration section from appsettings.json. Use when the user asks to remove logging, unregister the logging provider, or strip logging out of a Nano API, Web, or Console application. +--- + +# Nano remove logging provider + +Fully removes Nano logging from an existing Nano API, Web, or Console application - the +counterpart to `nano-add-logging-provider`. If the user actually wants to *switch* to a +different provider, that's the add skill's job (it already handles replacing an existing +provider); use this skill only when the end state should be no Nano logging provider +registered at all. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoLogging<...>()` + - the type argument tells you which provider. If none is registered, say so and stop; there's + nothing to remove. +2. **Is the package reference this skill's to remove?** Look for a `PackageReference` to + `Nano.Logging.` on the application project. If found (the project uses the + explicit/granular convention), remove it. + - If instead the project references `NanoCore` or `Nano.All` (the "quick start" convention - + see AGENTS.md), **leave it alone** - that package covers every Nano feature the project + uses, not just logging, so removing it would break unrelated functionality. There's simply + no package-level change to make in that case. + +## Program.cs + +Remove the `using Nano.Logging.Extensions;` and `using Nano.Logging.;` lines, and the +`.AddNanoLogging<...>()` call inside `.ConfigureServices(...)`. + +- If the lambda has no other statements left after removing the call, restore it to the + standard blank-app placeholder shape and rename the parameter back to the discard `_`: + `.ConfigureServices(_ => { // Add your services here. })`. Leaving an empty non-discard + parameter or a bare empty block behind looks like an unfinished edit. +- If other real service registrations remain in the lambda, just remove the one line - don't + touch the rest, and keep the parameter as `x`. + +## appsettings.json + +Remove the `Logging` section from the base `appsettings.json` entirely - it's a sibling of +`App`, added by the add-skill's `AGENTS.md`-documented shape. With no provider registered, +`LogLevel`/`LogLevelOverrides` are dead configuration nothing reads (the same class of bug as +leaving a Kubernetes probe pointed at a `HealthCheck` that was never enabled - don't leave it +behind). + +## After making the change + +- Show the user the modified `Program.cs` lines, the removed `appsettings.json` section, and - + if one was removed - the `PackageReference` taken out of the `.csproj`. +- If nothing needed to change at the package level because the project uses `NanoCore`/ + `Nano.All`, say so explicitly rather than leaving it unmentioned. +- Don't touch Docker/Kubernetes/CI files - logging provider selection has no effect on any of + those. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-metrics.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-metrics.prompt.md new file mode 100644 index 00000000..1dd93649 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-metrics.prompt.md @@ -0,0 +1,36 @@ +--- +mode: agent +description: Remove Nano's built-in OpenTelemetry metrics (App:Metrics) from a Nano API or Web application - removes the config and the Kubernetes ServiceMonitor. Use when the user asks to remove metrics, Prometheus, OpenTelemetry, or the /metrics endpoint from a Nano API or Web application. +--- + +# Nano remove metrics + +Removes Nano's `/metrics` endpoint from an existing Nano API or Web application - the counterpart +to `nano-add-metrics`. + +## Before making any change, determine + +1. **Is `App:Metrics` currently configured?** Check the base `appsettings.json`. If absent, say + so and stop. +2. **What depends on it?** Nothing in the app itself - Metrics has no dependents (confirmed + against `nano-add-metrics`'s own verification that it's independent of Health Checks, and + nothing else in the framework reads `App:Metrics`). The only external dependent is whatever + scrapes it - if Prometheus/Grafana dashboards are actively built on this endpoint, removing it + silently breaks that monitoring, with no error on the app side. Ask before removing if that + seems likely, since this app has no way to know it's being scraped. + +## Kubernetes + +Delete `.kubernetes/service-monitor.yaml`, and remove its apply block from the `Kubernetes +Deploy` workflow step. + +## appsettings.json + +Remove `App:Metrics` from the base `appsettings.json`. + +## After making the change + +- Show the user every file touched/deleted. +- If step 2's external-scraping concern applies, restate it - removing this leaves no error + anywhere in the app, only a monitoring dashboard that goes quiet. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-public-exposure.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-public-exposure.prompt.md new file mode 100644 index 00000000..a5489416 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-public-exposure.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +description: Remove public exposure from a Nano API or Web application - removes the HTTPS hosting config, Kubernetes HTTPRoute resources, and the CI hostname-derivation step. Cascades to removing Availability Check first, since that depends entirely on the app being publicly reachable. Use when the user asks to remove public exposure, take a Nano application private, or remove an HTTPRoute. +--- + +# Nano remove public exposure + +Removes public reachability from an existing Nano API or Web application - the counterpart to +`nano-add-public-exposure`. + +## Before making any change, determine + +1. **Is the app currently publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ + `httproute-443.yaml`. If neither exists, say so and stop. +2. **Is Availability Check configured?** Check the workflow for an "Add Availability Check" step. + **If so, this must be removed first, not left behind** - per `nano-add-availability-check`, + its ping test hits `https://$SUB_DOMAIN_NAME.$zoneName/healthz`, which stops resolving the + moment the `HTTPRoute`s are gone; leaving the check in place means it starts firing failure + alerts for an app that was deliberately taken private, not one that's actually down. Run + `nano-remove-availability-check` first, then continue with this skill - don't ask, this + cascade is the expected behavior, but tell the user it happened. + +## Kubernetes + +Delete `.kubernetes/httproute-80.yaml` and `.kubernetes/httproute-443.yaml`. `service.yaml` is +unaffected - it never needed to change to add exposure, so it doesn't need to change to remove +it either. + +## GitHub Actions + +Remove the `SUB_DOMAIN_NAME`/`AZURE_GROUP_DNS` env vars (unless Availability Check's own removal +already handled `AZURE_GROUP_DNS` - don't remove it twice or assume it's still needed elsewhere +without checking), the `$env:ROUTE_HOST_NAMES`/`$env:GATEWAY_NAME` derivation step, and the +`httproute-80.yaml`/`httproute-443.yaml` apply blocks from `Kubernetes Deploy`. + +## appsettings.json / docker-compose.yml + +Remove the `App:Hosting:Https`/`UseHttpsRedirection` block from `appsettings.Development.json`, +and the HTTPS port mapping + certificate volume from `docker-compose.yml`. Leave the base +`appsettings.json` alone - it was never changed by the add skill (HTTP stays exposed regardless). + +## After making the change + +- Show the user every file touched/deleted. +- If step 2's cascade applied, restate clearly that Availability Check was removed as a + consequence, not a separate request. +- If step 1 stopped the skill early, that's the whole response. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-startup-task.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-startup-task.prompt.md new file mode 100644 index 00000000..7f602759 --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-startup-task.prompt.md @@ -0,0 +1,32 @@ +--- +mode: agent +description: Remove a Startup Task from a Nano application - deletes the BaseStartupTask-derived class. Use when the user asks to remove a startup task, cache warm-up, or one-time initialization from a Nano API, Web, or Console application. +--- + +# Nano remove startup task + +Removes a Startup Task from an existing Nano API, Web, or Console application - the counterpart +to `nano-add-startup-task`. + +## Before making any change, determine + +1. **Which task?** Confirm the class name/file if the project has more than one - check + `Startup/` (or search for `BaseStartupTask`/`IStartupTask` if not in the conventional + location). +2. **Behavior change to flag, not a crash risk.** Nothing else in the app takes a required + dependency on a startup task's existence, so removal never breaks compilation or DI. The one + real effect: if [Health Checks](nano-add-health-checks) are enabled, the app's readiness gate + no longer waits on whatever this task was checking/warming - readiness becomes available + sooner, and whatever the task guaranteed (a warm cache, a verified dependency) is no longer + guaranteed before traffic is accepted. Tell the user this plainly if it seems load-bearing. + +## Startup task class + +Delete the file. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change. + +## After making the change + +- Show the user the file removed. +- Restate step 2 if the removed task looked like it was guarding something meaningful (an + external dependency check, a required warm-up) rather than being purely cosmetic. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-remove-storage-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-storage-provider.prompt.md new file mode 100644 index 00000000..23c04d6e --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-remove-storage-provider.prompt.md @@ -0,0 +1,88 @@ +--- +mode: agent +description: Remove a Nano storage provider (Local or Azure) from a Nano.Library-based application - unregisters it in Program.cs and removes the Storage configuration, local docker-compose volume mount, and the Kubernetes persistent volume (plus, for Azure, the Staging/Production fileshare-provisioning CI step). Use when the user asks to remove file storage, a fileshare, or a specific storage provider from a Nano API, Web, or Console application. +--- + +# Nano remove storage provider + +Fully removes a Nano storage provider from an existing Nano API, Web, or Console application - +the counterpart to `nano-add-storage-provider`. Read that skill first - this one undoes exactly +what it adds, file for file. + +## Before making any change, determine + +1. **Which provider is currently registered?** Check `Program.cs` for `.AddNanoStorage<...>()`. + If none, say so and stop. +2. **What depends on it?** Per AGENTS.md's `## Nano.Storage` section, registering a provider also + registers `IPathProvider`, "injectable anywhere" - search the project for it used as a + constructor parameter. A **required** `IPathProvider` parameter fails DI resolution the moment + the provider is gone - the app won't start at all. This is the only dependency risk here: + unlike Eventing, there's no declarative attribute (`[Publish]`/`[Subscribe]`) tied to storage + that would silently stop working instead - anything using it does so explicitly, in code. If a + required injection exists, tell the user removing the provider will crash the app there and + confirm before proceeding. +3. **Is the package reference this skill's to remove?** Same check as the other remove skills: + leave `NanoCore`/`Nano.All` alone if present; otherwise remove the `Nano.Storage.` + `PackageReference` from the application project. + +## Program.cs + +Remove `using Nano.Storage.Extensions;`, `using Nano.Storage.;`, and the +`.AddNanoStorage<...>()` call. Same empty-lambda cleanup as the other remove-provider skills: +restore the blank-app placeholder and `_` parameter if nothing else is left in +`.ConfigureServices(...)`. + +## appsettings.json + +Remove the `Storage` section entirely from the base `appsettings.json`. There's no +Development-specific override to also clean up - per `nano-add-storage-provider`, `ShareName` +isn't sensitive and stays in the base file only, for both providers. + +## docker-compose.yml + +Remove the `volumes` entry mapping `./bin/:/mnt/` from the app's own +service in `.docker/docker-compose.yml`. Unlike a data or eventing provider, storage never added +a separate service container - just this one volume line - so there's nothing else to remove +here. + +## Kubernetes - Local + +- Delete `.kubernetes/storage-storageclass.yaml` and `.kubernetes/service-headless.yaml`. +- If the app was converted to a `StatefulSet` for this provider (`.kubernetes/stateful-set.yaml` + present, `serviceName: %SERVICE_NAME%-stateful-headless` set), revert it to a plain `Deployment`: + rename the file back to `deployment.yaml`, change `kind: StatefulSet` → `kind: Deployment`, + remove the `serviceName` field, and remove the `volumeClaimTemplates` block (there's no static + `PersistentVolumeClaim` file to restore in its place - the volume is gone entirely, not + replaced). `.kubernetes/autoscaler.yaml` is always present on an API/Web app - if its + `scaleTargetRef.kind` was changed to `StatefulSet` (i.e. the app was converted per the above), + change it back to `Deployment`. +- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from the + deployment/stateful-set container spec. Also remove the `tmp` `emptyDir` volume/mount + (`IPathProvider`'s temporary directory, per AGENTS.md) - but only if nothing else in the + container mounts `/tmp` for an unrelated reason; check first. +- Remove the `storage-storageclass.yaml`/`service-headless.yaml` apply blocks from the + `Kubernetes Deploy` workflow step. +- Remove the `STORAGE_SIZE`/`STORAGE_SHARE_NAME` workflow env vars, if nothing else uses them. + +## Kubernetes - Azure + +- Delete `.kubernetes/storage-pv.yaml` and `.kubernetes/storage-pvc.yaml`. +- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from + `.kubernetes/deployment.yaml`. Also remove the `tmp` `emptyDir` volume/mount, with the same + caveat as the Local section above - only if nothing else needs `/tmp`. +- Remove the `Storage Role Permissions` and `Create Fileshare` workflow steps, and the + `$env:VOLUME_NAME_SUFFIX = ...` derivation step, if nothing else in the workflow still uses + `%VOLUME_NAME_SUFFIX%`. +- Remove the `storage-pv.yaml`/`storage-pvc.yaml` apply block from `Kubernetes Deploy`. +- Remove the `STORAGE_SIZE`/`STORAGE_SHARE_NAME` workflow env vars. Only remove + `AZURE_GROUP_STORAGE`/`AZURE_GROUP_BACKUP` if nothing else in the workflow still references + them - Managed Identity or other Azure-backed providers may share them. + +## After making the change + +- Show the user every file touched/deleted, grouped by concern (app code, local docker-compose, + and for Azure, Staging/Production CI + K8s) - same reasoning as the add skill: too many files + for a flat list to be easy to sanity-check. +- Restate anything flagged in step 2 - a required `IPathProvider` injection that will now crash + the app - one more time here, even if the user already confirmed it. +- If a step was skipped because the project uses `NanoCore`/`Nano.All`, say so explicitly. diff --git a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml new file mode 100644 index 00000000..94d9efcc --- /dev/null +++ b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml @@ -0,0 +1,227 @@ +name: Build And Deploy +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: +env: + APP_NAME: Api.Auth.External.Microsoft + IMAGE_NAME: api.auth.external.microsoft + SERVICE_NAME: api-auth-external-microsoft + VERSION: '${{ vars.VERSION }}.${{ github.run_number }}.${{ github.run_attempt }}' + DOTNET_SDK_VERSION: "10.0" + DOTNET_ASPNET_VERSION: "10.0" + AZURE_GROUP_KUBERNETES: ${{ vars.AZURE_RESOURCE_GROUP_KUBERNETES }} + AZURE_GROUP_DELIVERY: ${{ vars.AZURE_RESOURCE_GROUP_DELIVERY }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SUBSCRIPTION_ID: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AZURE_SUBSCRIPTION_ID || secrets.STAGING_AZURE_SUBSCRIPTION_ID }} + NUGET_HOST: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json + NUGET_USERNAME: ${{ github.actor }} + NUGET_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + KUBERNETES_NODEPOOL_COMPUTE: cpu + KUBERNETES_NAMESPACE: ${{ vars.KUBERNETES_NAMESPACE }} + KUBERNETES_REPLICA_COUNT: ${{ github.ref == 'refs/heads/main' && 3 || 2 }} + KUBERNETES_REPLICA_COUNT_MAX: ${{ github.ref == 'refs/heads/main' && 8 || 5 }} + KUBERNETES_REPLICA_HISTORY_COUNT: 0 + KUBERNETES_MEMORY_REQUEST: 512Mi + KUBERNETES_MEMORY_LIMIT: 1536Mi + KUBERNETES_MEMORY_SCALING: 180 + KUBERNETES_CPU_REQUEST: 200m + KUBERNETES_CPU_LIMIT: 600m + KUBERNETES_CPU_SCALING: 180 + AUTH_JWT_PUBLIC_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PUBLIC_KEY || secrets.STAGING_AUTH_JWT_PUBLIC_KEY }} + AUTH_JWT_PRIVATE_KEY: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_AUTH_JWT_PRIVATE_KEY || secrets.STAGING_AUTH_JWT_PRIVATE_KEY }} + AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} + ASPNETCORE_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Staging' }} +concurrency: + group: ${{ github.workflow }}-${{ github.repository }} + cancel-in-progress: true +jobs: + build-and-deploy: + runs-on: + - self-hosted + - linux + - ${{ github.ref == 'refs/heads/main' && 'Production' || 'Staging' }} + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Azure Login + shell: pwsh + run: | + az login --service-principal -u $env:AZURE_CLIENT_ID -p $env:AZURE_CLIENT_SECRET --tenant $env:AZURE_TENANT_ID -o none; + az account set -s $env:AZURE_SUBSCRIPTION_ID -o none; + + $env:KUBERNETES_CLUSTER = az aks list -g $env:AZURE_GROUP_KUBERNETES --query [0].name -o tsv; + az aks get-credentials -g $env:AZURE_GROUP_KUBERNETES -n $env:KUBERNETES_CLUSTER --overwrite -o none; + + - name: Build Solution + shell: pwsh + run: | + dotnet nuget add source $env:NUGET_HOST -n private -u $env:NUGET_USERNAME -p $env:NUGET_PASSWORD --store-password-in-clear-text; + + dotnet build -c Release .\$env:APP_NAME.sln; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + - name: Test Solution + shell: pwsh + run: | + dotnet test .\.tests\Tests.$env:APP_NAME\Tests.$env:APP_NAME.csproj; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + - name: Publish NuGet + shell: pwsh + run: | + $nugetProjectModels=$env:APP_NAME + ".Models/" + $env:APP_NAME + ".Models.csproj"; + dotnet pack $nugetProjectModels -c Release --output nupkgs /p:PackageVersion=$env:VERSION --include-symbols --no-build; + dotnet nuget push nupkgs/$env:APP_NAME".Models."$env:VERSION.nupkg -s $env:NUGET_HOST -k $env:NUGET_PASSWORD; + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + - name: Build & Push Image + shell: pwsh + run: | + $env:CONTAINER_REGISTRY_HOST = az acr list -g $env:AZURE_GROUP_DELIVERY --query [0].loginServer -o tsv; + + az acr build ` + --registry $env:CONTAINER_REGISTRY_HOST ` + --agent-pool buildpool ` + -t "$($env:IMAGE_NAME):latest" ` + -t "$($env:IMAGE_NAME):$($env:VERSION)" ` + --build-arg DOTNET_SDK_VERSION=$env:DOTNET_SDK_VERSION ` + --build-arg DOTNET_ASPNET_VERSION=$env:DOTNET_ASPNET_VERSION ` + --build-arg CONTAINER_REGISTRY_SOURCE_LABEL=https://github.com/$env:GITHUB_REPOSITORY ` + --build-arg NUGET_HOST=$env:NUGET_HOST ` + --build-arg NUGET_USERNAME=$env:NUGET_USERNAME ` + --build-arg NUGET_PASSWORD=$env:NUGET_PASSWORD ` + ./ + + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + echo "CONTAINER_REGISTRY_HOST=$env:CONTAINER_REGISTRY_HOST" >> $env:GITHUB_ENV; + + - name: Setup App Registration + shell: pwsh + 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_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 AzureADMyOrg ` + --web-redirect-uris $env: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 + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + } + + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $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 { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + 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; + + - name: Kubernetes Deploy + shell: pwsh + run: | + Get-Content .kubernetes/auth-jwt-secret.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/auth-jwt-secret.tmp.yaml; + kubectl apply -f .kubernetes/auth-jwt-secret.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + Get-Content .kubernetes/auth-microsoft-secret.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/auth-microsoft-secret.tmp.yaml; + kubectl apply -f .kubernetes/auth-microsoft-secret.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + Get-Content .kubernetes/service.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/service.tmp.yaml; + kubectl apply -f .kubernetes/service.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + Get-Content .kubernetes/configmap.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/configmap.tmp.yaml; + kubectl apply -f .kubernetes/configmap.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + Get-Content .kubernetes/deployment.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/deployment.tmp.yaml; + kubectl apply -f .kubernetes/deployment.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + Get-Content .kubernetes/autoscaler.yaml | foreach { [Environment]::ExpandEnvironmentVariables($_) } | Set-Content .kubernetes/autoscaler.tmp.yaml; + kubectl apply -f .kubernetes/autoscaler.tmp.yaml; + if ($LastExitCode -ne 0) + { + throw "error"; + }; + + - name: GitHub Release + if: github.ref == 'refs/heads/main' + uses: ncipollo/release-action@v1 + with: + tag: v${{ env.VERSION }} + name: "Release ${{ env.VERSION }}" + body: | + Version: ${{ env.VERSION }} + Commit: ${{ github.sha }} + Build run: ${{ github.run_number }} + Image: ${{ env.CONTAINER_REGISTRY_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }} + artifacts: "nupkgs/*" + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false diff --git a/Api.Auth.External.Microsoft/.gitignore b/Api.Auth.External.Microsoft/.gitignore new file mode 100644 index 00000000..ccb0126f --- /dev/null +++ b/Api.Auth.External.Microsoft/.gitignore @@ -0,0 +1,10 @@ +.vs +*.user +*.userprefs +*.suo +_ReSharper* +**/bin +**/obj +*.DotSettings.User +packages +.env \ No newline at end of file diff --git a/Api.Auth.External.Microsoft/.kubernetes/auth-jwt-secret.yaml b/Api.Auth.External.Microsoft/.kubernetes/auth-jwt-secret.yaml new file mode 100644 index 00000000..3898973f --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/auth-jwt-secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: auth-jwt-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + jwt-public-key: %AUTH_JWT_PUBLIC_KEY% + jwt-private-key: %AUTH_JWT_PRIVATE_KEY% + diff --git a/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml b/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml new file mode 100644 index 00000000..48e4af89 --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/auth-microsoft-secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AZURE_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% diff --git a/Api.Auth.External.Microsoft/.kubernetes/autoscaler.yaml b/Api.Auth.External.Microsoft/.kubernetes/autoscaler.yaml new file mode 100644 index 00000000..95add8ad --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/autoscaler.yaml @@ -0,0 +1,25 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: %SERVICE_NAME%-hpa + namespace: %KUBERNETES_NAMESPACE% +spec: + minReplicas: %KUBERNETES_REPLICA_COUNT% + maxReplicas: %KUBERNETES_REPLICA_COUNT_MAX% + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: %SERVICE_NAME% + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: %KUBERNETES_CPU_SCALING% + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: %KUBERNETES_MEMORY_SCALING% diff --git a/Api.Auth.External.Microsoft/.kubernetes/configmap.yaml b/Api.Auth.External.Microsoft/.kubernetes/configmap.yaml new file mode 100644 index 00000000..d13013d1 --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: %SERVICE_NAME%-config + namespace: %KUBERNETES_NAMESPACE% +data: + App__Version: %VERSION% + ASPNETCORE_ENVIRONMENT: %ASPNETCORE_ENVIRONMENT% diff --git a/Api.Auth.External.Microsoft/.kubernetes/deployment.yaml b/Api.Auth.External.Microsoft/.kubernetes/deployment.yaml new file mode 100644 index 00000000..3d1d25ba --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: %SERVICE_NAME% + namespace: %KUBERNETES_NAMESPACE% + labels: + app: %SERVICE_NAME% +spec: + replicas: %KUBERNETES_REPLICA_COUNT% + revisionHistoryLimit: %KUBERNETES_REPLICA_HISTORY_COUNT% + selector: + matchLabels: + app: %SERVICE_NAME% + template: + metadata: + labels: + app: %SERVICE_NAME% + spec: + automountServiceAccountToken: false + securityContext: + runAsUser: 1000 + runAsGroup: 2000 + fsGroup: 2000 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: %SERVICE_NAME% + nodeSelector: + nodepool.compute: %KUBERNETES_NODEPOOL_COMPUTE% + kubernetes.io/os: linux + containers: + - name: %SERVICE_NAME% + image: %CONTAINER_REGISTRY_HOST%/%IMAGE_NAME%:%VERSION% + ports: + - containerPort: 8080 + imagePullPolicy: Always + env: + - name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key + - name: App__Authentication__Jwt__PrivateKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-private-key + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret + envFrom: + - configMapRef: + name: %SERVICE_NAME%-config + resources: + requests: + memory: %KUBERNETES_MEMORY_REQUEST% + cpu: %KUBERNETES_CPU_REQUEST% + limits: + memory: %KUBERNETES_MEMORY_LIMIT% + cpu: %KUBERNETES_CPU_LIMIT% + securityContext: + privileged: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 2000 + capabilities: + drop: + - ALL diff --git a/Api.Auth.External.Microsoft/.kubernetes/service.yaml b/Api.Auth.External.Microsoft/.kubernetes/service.yaml new file mode 100644 index 00000000..2d8e20b2 --- /dev/null +++ b/Api.Auth.External.Microsoft/.kubernetes/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: %SERVICE_NAME% + namespace: %KUBERNETES_NAMESPACE% +spec: + ports: + - name: http + port: 8080 + selector: + app: %SERVICE_NAME% + type: ClusterIP diff --git a/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Properties/DoNotParallelize.cs b/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Properties/DoNotParallelize.cs new file mode 100644 index 00000000..6a076669 --- /dev/null +++ b/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Properties/DoNotParallelize.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: DoNotParallelize] \ No newline at end of file diff --git a/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Tests.Api.Auth.External.Microsoft.csproj b/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Tests.Api.Auth.External.Microsoft.csproj new file mode 100644 index 00000000..1a059b99 --- /dev/null +++ b/Api.Auth.External.Microsoft/.tests/Tests.Api.Auth.External.Microsoft/Tests.Api.Auth.External.Microsoft.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + false + latest + + + true + + + + + + + + + + + + + + diff --git a/Api.Auth.External.Microsoft/.vscode/settings.json b/Api.Auth.External.Microsoft/.vscode/settings.json new file mode 100644 index 00000000..909007c1 --- /dev/null +++ b/Api.Auth.External.Microsoft/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "chat.promptFiles": true +} diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md new file mode 100644 index 00000000..9da98eba --- /dev/null +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -0,0 +1,3428 @@ +# AGENTS.md — Nano Framework + +Implementation reference for building applications with Nano. Structured to mirror the module READMEs in this +repository (`Nano.App`, `Nano.App.Api`, `Nano.App.Console`, `Nano.App.Web`, `Nano.Logging`, `Nano.Data`, +`Nano.Eventing`, `Nano.Storage`), so a section here maps 1:1 to a section there. + +## Core Principle — Built-In Before Custom + +Every extension point documented below (a custom data/eventing/logging provider, a custom Api Client method, a +custom query criteria operation, a hand-rolled controller action) exists for when the built-in surface genuinely +can't express what's needed — not as a default starting point. Before reaching for any custom implementation, +check whether the generic/built-in path already covers it: +- Generic entity CRUD (`.Entity`) + `[Include]`-driven eager loading + `DynamicExpression` query criteria, before + a custom Api Client method or controller action. +- A built-in data/eventing/logging/storage provider, before a custom `IDataProvider`/`IEventingProvider`/ + `ILoggingProvider`/custom storage implementation. +- The built-in `.Auth`/`.Audit`/`.Identity` method groups, before reimplementing that behavior by hand. + +A custom implementation is the last resort once the built-in path is confirmed insufficient, not a shortcut past +learning how the built-in one works. Every skill that scaffolds something Nano already has a built-in way to do +applies this same check before generating anything — see each skill's own "is this actually needed" determination +step. + +--- + +## Solution Structure + +Every Nano application — Api, Web, or Console — follows the same predictable solution layout. `{name}` is the +application's own name (e.g. `Svc.Accounts`); `{name}.Models` is a **separate, sibling project**, not nested +inside `{name}/`. + +| Directory / File | API | WEB | CON | Description | +| -------------------------------------------------------- | --- | --- | --- | ------------------------------------------------------------------------------------------------------------------------- | +| `{name}.sln` | ✓ | ✓ | ✓ | The Visual Studio solution file, at the solution root. | +| `{name}/{name}.csproj` | ✓ | ✓ | ✓ | The application project file. | +| `{name}/Program.cs` | ✓ | ✓ | ✓ | Entry point — where the application is configured, built, and run. | +| `{name}/Properties/InternalsVisibleTo.cs` | ✓ | ✓ | ✓ | Exposes internal types to the test project. | +| `{name}/appsettings.json` | ✓ | ✓ | ✓ | Default application configuration. | +| `{name}/appsettings.{environment}.json` | ✓ | ✓ | ✓ | Overrides for `Development`, `Staging`, `Production`. | +| `{name}/Controllers/` | ✓ | ✓ | ✗ | Concrete controllers (conventional location, not a hard requirement). | +| `{name}/Data/` | ✓ | ✓ | ✓ | `DbContext`, `DbContextFactory`, and `Mappings/` (conventional location). | +| `{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}.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. | +| `.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/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. | +| `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | +| `.kubernetes/stateful-set.yaml` | (✓) | (✓) | ✗ | Kubernetes StatefulSet, replacing `deployment.yaml` _(only when the app needs one persistent volume per pod, not one shared — e.g. a `Local` storage provider backed by a single-attach (`ReadWriteOnce`) disk; a plain `Deployment` would have every replica race to attach the same volume. Uses `volumeClaimTemplates` instead of a static `PersistentVolumeClaim` file. Requires `service-headless.yaml` below for its `serviceName` field)_. | +| `.kubernetes/service.yaml` | ✓ | ✓ | ✗ | Kubernetes Service. | +| `.kubernetes/service-headless.yaml` | (✓) | (✓) | ✗ | Governing headless Service (`clusterIP: None`) — only present alongside `stateful-set.yaml`, which requires one for its `serviceName` field. Not a substitute for `service.yaml`, which still handles normal traffic routing. | +| `.kubernetes/httproute-80.yaml` | (✓) | (✓) | ✗ | Kubernetes HTTPRoute redirecting HTTP → HTTPS _(optional, public-facing apps only — always paired with `httproute-443.yaml` below, never present alone)_. | +| `.kubernetes/httproute-443.yaml` | (✓) | (✓) | ✗ | Kubernetes HTTPRoute routing HTTPS traffic to the app _(optional, public-facing apps only)_. | +| `.kubernetes/service-account.yaml` | (✓) | (✓) | (✓) | Kubernetes ServiceAccount annotated for Azure Workload Identity _(optional, only when the app uses Azure Managed Identity)_. | +| `.kubernetes/service-monitor.yaml` | (✓) | (✓) | ✗ | Prometheus `ServiceMonitor` scraping `/metrics` _(optional, only when Metrics is enabled — API/Web only, no HTTP surface on Console)_. | +| `.kubernetes/cronjob.yaml` | ✗ | ✗ | ✓ | Kubernetes CronJob (Console apps run as scheduled jobs, not long-running Deployments). | +| `.github/workflows/build-and-deploy.yml` | ✓ | ✓ | ✓ | CI/CD workflow — build, test, publish, deploy. | +| `Dockerfile` | ✓ | ✓ | ✓ | Container image build for `Staging`/`Production`, at the solution root. | +| `.dockerignore` / `.gitignore` | ✓ | ✓ | ✓ | Solution root. | +| `README.md` / `icon.png` / `LICENSE` | (✓) | (✓) | (✓) | Solution root, optional — used for the repo and any published NuGet packages. | + +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 +conventionally belong. + +⚠ `{name}.sln` lists every file under `.kubernetes/` (and `.github/workflows/`) explicitly, one line per file, +inside that folder's `ProjectSection(SolutionItems) = preProject` block — Visual Studio doesn't pick these up +automatically the way it does `.csproj`-owned source files. Adding a new `.kubernetes/*.yaml` manifest (a new +Kubernetes secret, storage class, HTTPRoute, etc.) means also adding a `.kubernetes\.yaml = .kubernetes\.yaml` +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 +`{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, +and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: +- `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base + types). +- The application-type package (`Nano.App.Api`, `Nano.App.Console`, or `Nano.App.Web`) and every provider package + (e.g. `Nano.Data.PostgreSQL`, `Nano.Logging.Serilog`) go on `{name}` (the app project) — `{name}` gets `Nano.App` + transitively through the application-type package, so it never needs `Nano.App` directly too. + +**Non-`Guid` identity**: Nano defaults every generic surface to `Guid` via a non-generic shorthand +(`BaseEntity` = `BaseEntity`, `IRepository` = `Repository`, etc.). ⭐ It's highly +recommended to just use `Guid` throughout — it's the path every non-generic shorthand and every real example in +this doc is built around. Using a different identity type (`int`, `long`, `string`, or a custom +`IEquatable`) means threading the same `TIdentity` through **every** one of these consistently — there's no +single place that "sets" it once: + +- **Data**: `AddNanoData()`, `BaseDbContext`, every entity base class + (`BaseEntity`, etc.) and mapping base class. +- **Repository**: the concrete `Repository` registered behind `IRepository`. +- **Controllers**: `BaseEntityController` and siblings, `BaseAuthController`, + `BaseAuditController`. +- **Authentication**: `IAuthRepository`, `IAuthIdentityRepository`, `IIdentityRepository`. +- **Api Client**: `BaseApiClient`, `BaseIdentityApiClient`, and generic requests + (`DetailsRequest`, `DeleteRequest`, etc.). +- **Audit**: `AuditEntry`, `AuditEntryProperty`. +- **Identity entity models**: `IdentityUserEx`, `IdentityRole`, etc. + +Mixing identity types across these — e.g. an `int`-keyed entity registered against a `Guid`-typed repository — +doesn't compile or bind correctly. If everything stays `Guid`, none of this matters; it's only relevant the +moment one non-default identity type is chosen anywhere in the app. + +--- + +## Nano.App + +Common services shared by every Nano application type (Api, Console, Web). Transitive — never referenced +directly by an app project. + +### Environment + +Nano is environment-neutral: behavior differs only through `appsettings.{environment}.json`, never through +environment-specific code. The environment is read from `DOTNET_ENVIRONMENT` or `ASPNETCORE_ENVIRONMENT`, +defaulting to `Development`. + +| Environment | Type | Description | +| ------------- | ------ | ----------------------------- | +| `Development` | Local | Local development machine. | +| `Staging` | Cloud | Cloud Kubernetes deployment. | +| `Production` | Cloud | Cloud Kubernetes deployment. | + +### Configuration + +Standard .NET configuration providers, with precedence (later overrides earlier): + +1. `appsettings.json` +2. `appsettings.{environment}.json` +3. Command-line arguments +4. Environment variables +5. User secrets (`Development` only) + +Two deviations from stock .NET behavior: +- An **empty** configuration section is mapped with all default values — it is not treated as absent. +- Setting a section to **`null`** in an environment-specific file removes/overrides a section defined in the + base `appsettings.json` (stock .NET silently ignores a `null` override; Nano honors it as a deletion). + +### Null Logger + +If no logging provider is registered (see [Nano.Logging](#nanologging)), Nano still registers `ILoggerFactory`, +`ILogger`, and `ILogger` — backed by a `NullLogger` that discards everything. This is a safety fallback so +code that injects `ILogger` never fails to resolve, even with no logging provider configured. + +### Api Clients + +This is the mechanism for one Nano application to call another over HTTP with a typed, strongly-modeled client — +full CRUD against the target's entities, authentication, and identity management, without hand-building HTTP +requests. It's how internal services expose their models/entities to other applications (typically via a NuGet +built from their `{name}.Models` project — see [Solution Structure](#solution-structure)), and how a +publicly-exposed Public API composes several internal services into one façade (see +[Controllers § Public API vs internal service](#public-api-vs-internal-service)). + +**Where the code lives**: the client class and its custom request types live in the *owning* service's +`{name}.Models/Api/` project (e.g. `MyService.Models/Api/MyApi.cs`, with custom requests under +`Api/Requests/`). A consuming application references that project (or its published NuGet) and injects the +client class directly — no manual DI registration needed. + +#### Defining a client + +Derive from `BaseApiClient` (`Guid` identity), `BaseApiClient` (custom identity type), or — if the +target application has Identity configured — `BaseIdentityApiClient`/`BaseIdentityApiClient`, where `TUser` is the target's `IEntityUser` model. The constructor must take exactly `ApiClient`. + +Three shapes: + +```csharp +// Bare pass-through — no custom methods, relies entirely on the built-in .Entity/.Auth/.Audit groups +public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); +``` + +```csharp +// Custom methods only, wrapping one hand-defined request each +public class MyOtherApi(ApiClient apiClient) : BaseApiClient(apiClient) +{ + // No response — InvokeAsync + public virtual Task MyMethodAsync(MyModel model, CancellationToken cancellationToken = default) + => this.InvokeAsync(new MyRequest { Model = model }, cancellationToken); + + // Typed response — InvokeAsync; MyResponse is a plain POCO, no base type required + public virtual Task GetMyResponseAsync(MyRequest request, CancellationToken cancellationToken = default) + => this.InvokeAsync(request, cancellationToken); +} +``` + +```csharp +// Identity-backed target — adds the .Identity method group +public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient) +{ + public virtual Task GetByEmailAsync(string emailAddress, CancellationToken cancellationToken = default) + => this.InvokeAsync(new GetByEmailRequest { EmailAddress = emailAddress }, cancellationToken); +} +``` + +#### Built-in method groups + +Available as properties on the client instance — no implementation needed, just call them: + +| Group | Available on | Covers | +| -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | +| `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | + +An endpoint not enabled on the target application (e.g. `.Auth` when the target has no authentication configured) +returns `404` — surfaced as `null`, not an exception (see Gotchas below). + +Real usage — a controller composing multiple clients (an identity-backed `MyApi` plus a custom-methods-only +`MyOtherApi`) into one Public API endpoint: + +```csharp +public class MyUserController(ILogger logger, MyApi myApi, MyOtherApi myOtherApi) + : BaseController(logger) +{ + public virtual async Task GetMyUserAsync(Guid id, CancellationToken cancellationToken = default) + { + var entity = await myApi.Entity.GetAsync(id, cancellationToken); + return entity == null ? this.NotFound() : this.Ok(entity); + } + + public virtual async Task SignUpAsync([FromBody][Required] MyUser entity, CancellationToken cancellationToken = default) + { + var user = await myApi.Identity.SignUpAsync(new SignUpRequest { SignUp = new SignUp { User = entity } }, cancellationToken); + + await myOtherApi.MyMethodAsync(new MyModel { UserId = user.Id }, cancellationToken); + + return this.Created("signup", user); + } +} +``` + +#### Custom requests (endpoints beyond CRUD/Auth/Identity) + +1. Derive a request from `BaseRequest`, annotated with an action attribute naming the HTTP verb + relative route: + `[GetAction]`, `[PostAction]`, `[PutAction]`, `[DeleteAction]`, `[PatchAction]`, `[QueryAction]`, `[HeadAction]`, + `[OptionsAction]`, `[ConnectAction]`. +2. Annotate properties with parameter attributes: + +| Attribute | Purpose | +| ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `[Route(Order = n)]` | Positional route-template substitution (`{n}` placeholders in the action's route string, filled in `Order` sequence). | +| `[Query]` | Querystring parameter (scalar types); optional `Name` override. | +| `[Body]` | The JSON request body (one complex object). | +| `[Form]` | A `multipart/form-data` field — scalar, or `IFormFile`/`FileInfo`/`FileStream`/`Stream`/`NamedStream`; complex objects need `[FromFormBody]` server-side. Mutually exclusive with `[Body]`. | +| `[Header(Name=..., ValuePrefix=...)]` | An HTTP header key/value. | + +Four shapes, covering every parameter attribute: + +```csharp +[GetAction("all")] +public class GetAllRequest : BaseRequest; // no params — controller inferred from TResponse (e.g. IEnumerable -> "MyEntities") + +[GetAction("by-name")] +public class MyQueryRequest : BaseRequest +{ + [Query] public virtual string Name { get; set; } = null!; +} + +[GetAction("{id}/file/{type}")] +public class MyFileRequest : BaseRequest +{ + [Route(Order = 0)] public virtual Guid Id { get; set; } + [Route(Order = 1)] public virtual MyEnum Type { get; set; } + + public MyFileRequest() { this.Controller = "MyEntities"; } // explicit override — route doesn't match a pluralized TResponse +} + +[PostAction("{id}/file/set")] +public class SetMyFileRequest : BaseRequest +{ + [Route] public virtual Guid Id { get; set; } + [Form] public virtual IFormFile File { get; set; } = null!; + + public SetMyFileRequest() { this.Controller = "MyEntities"; } +} +``` + +3. Add a method to the client, calling `InvokeAsync` (no response) or `InvokeAsync` + (typed response — use `NamedStream` or `Stream` for file downloads). + +**Controller resolution**: if a request doesn't set `this.Controller` explicitly in its constructor, it's +inferred as the pluralized `TResponse` type name (e.g. `IEnumerable` → `MyEntities`). Set it explicitly +whenever the route doesn't naturally match the response type, or the request has no typed response at all. + +**Keep the route string in sync with the server.** Both sides declare the same route segment independently — the +action attribute here, and `[Route(...)]` on the target controller's action — with nothing enforcing they match. +Nano's own built-in requests avoid this by referencing shared constants (`Nano.Common.Consts.ActionRoutes`) from +both sides, e.g. `BaseEntityViewController` uses `[Route(ActionRoutes.INDEX)]` while the built-in `IndexRequest` +uses `[PostAction(ActionRoutes.INDEX)]` — one string, referenced twice, so a rename can't silently break the +client without also breaking the build. For your own custom endpoints, define the route segment as a constant in +a `Consts` class inside the shared `{name}.Models` project (visible to both the owning API project and any +client-Api consumer) and reference it from both the request's action attribute and the controller's `[Route(...)]`, +instead of retyping the same literal string in two places. + +#### Configuration + +Registered automatically — no `services.AddNanoApiClient()` call needed. Every `BaseApiClient` subclass in +the entry assembly whose class name matches a key under `App:Apis` gets wired up (`HttpClient` + `ApiClient` + +the client instance) and becomes injectable. + +| Setting | Type | Default | Description | +| ---------------------------- | -------- | --------- | ------------------------------------------------------------------------------------ | +| `Host` | string | localhost | Target API host. | +| `Root` | string | api | Root path segment. | +| `Port` | int | 80 | Target port. | +| `UseSsl` | bool | false | Use HTTPS. | +| `Timeout` | TimeSpan | 00:00:30 | Request timeout. | +| `LogInRoot.Username` | string | null | Optional — auto-login as root if no inbound JWT is available to forward. | +| `LogInRoot.Password` | string | null | Optional — paired with `LogInRoot.Username`. | +| `HealthCheck.UnhealthyStatus` | enum | Unhealthy | Status reported when the target is unreachable. API/Web apps only. | + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30", + "HealthCheck": { "UnhealthyStatus": "Unhealthy" } + } + } +} +``` + +⚠ The dictionary key **must exactly match the client's class name** (`MyApi` above) — this is the only +link between config and DI; there's no other place to declare which config entry a client uses. + +#### Authentication forwarding + +Outbound JWT is resolved in this order: `request.JwtTokenOverride` (explicit per-request override) → the +current inbound request's own JWT (so a call made from inside a controller/worker action transparently forwards +the caller's identity — this is how a Public API's controllers stay authenticated end-to-end into an +internal service) → if `LogInRoot` is configured, an automatic root login (cached for the process lifetime). A +set of headers (`X-Api-Key`, `X-Forwarded-*`, request id, `Accept-Language`, timezone) is also forwarded +automatically from the inbound `HttpContext`, so locale/tenant/tracing context survives across service calls. + +Console workers (which have no inbound `HttpContext`) typically call only anonymous/unauthenticated endpoints to +avoid needing `LogInRoot` credentials — a worker with no `LogInRoot` configured at all can still call target +endpoints that are `[AllowAnonymous]`. + +⚠ **Don't have the target service re-derive caller-context claim values (tenant id, user id, etc.) from its own +copy of the forwarded JWT for a custom endpoint's business logic.** The JWT is forwarded automatically (above), +but a custom action's logic should receive that data as an explicit field on the request, read by the *calling* +application from its own already-validated JWT and passed down — not re-extracted downstream from the token +Nano happens to forward alongside it. This keeps claim-parsing logic in one place, and avoids needing the exact +same tenant to exist and match on both sides in `Development` just to exercise a downstream custom endpoint — +pass the value explicitly and the target doesn't need a real, matching tenant behind the token to use it. + +#### Gotchas + +- A configured-but-never-injected client is **not** registered — Nano only wires up clients actually referenced + somewhere in the app. +- `404` responses return `null`, never throw — always null-check rather than try/catch. +- Other non-success responses throw `ProblemDetailsException` (structured `ProblemDetails`) or, if the body + isn't parseable as `ProblemDetails`, `ApiClientException` (raw body + status code). +- 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). + +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**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. + +```xml + + + + + + + +``` + +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). + +⚠ 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 +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + +### Start-Up Tasks + +One-time initialization work that must complete before the application starts accepting traffic (or, for +Console apps, before workers start) — cache warm-up, external dependency checks, or similar. Not the same +mechanism as the built-in database migration task Nano runs for a configured data provider. + +#### Defining a task + +Implement `IStartupTask` (`OnStartAsync`/`OnStopAsync`), or derive from `BaseStartupTask` to only need +`OnStartAsync` — its `OnStopAsync` defaults to `Task.CompletedTask`. + +```csharp +public class MyStartupTask(ILogger logger) : BaseStartupTask(logger) +{ + public override async Task OnStartAsync(CancellationToken cancellationToken = default) + { + // one-time init — cache warm-up, external dependency check, etc. + } + + // optional — only override if you need it; see the timing note below before relying on it + public override async Task OnStopAsync(CancellationToken cancellationToken = default) + { + // cleanup for what OnStartAsync acquired — runs right after OnStartAsync completes, not at real shutdown + } +} +``` + +No registration needed — just define the class in the entry assembly. Every non-abstract `IStartupTask` +implementation is discovered by reflection and registered `Scoped`; any other registered service, including +scoped ones, can be injected into the constructor. + +#### Execution + +All registered tasks' `OnStartAsync` run **concurrently** (`Task.WhenAll`), in one shared service scope, before +the application accepts requests. If any task throws, the exception propagates and **the application fails to +start** — a startup task is not allowed to fail silently. + +⚠ **`OnStopAsync` is not "runs at application shutdown."** Immediately after all `OnStartAsync` calls complete, +Nano's internal hosted service calls its own stop routine right away — which invokes every task's `OnStopAsync` +and decrements a shared readiness counter (`StartupTaskContext`). So `OnStopAsync` actually fires right after +`OnStartAsync` finishes, as a completion/cleanup hook — not tied to real application shutdown (though the host's +real shutdown sequence may also invoke it again). Use it for cleanup that belongs immediately after the task's +own startup work, not for logic that must run when the application actually stops. + +#### Readiness integration + +The same readiness counter backs the built-in *self* startup health check: once [Health Checks](#health-checks) +are enabled, the application isn't reported healthy/ready until every startup task's `OnStartAsync` **and** +`OnStopAsync` have completed. In Console apps, workers don't start until this same counter reaches zero — startup +tasks always run to completion before the first worker starts. + +Conventionally placed in a `Startup/` folder in the application project (not a hard requirement — discovered by +type, not location). + +### Custom Services + +Standard ASP.NET Core dependency injection — nothing Nano-specific beyond the extension point. Register anything +in the `ConfigureServices(...)` step alongside the `AddNanoX<...>()` provider calls: + +```csharp +NanoApiApplication + .ConfigureApp() + .ConfigureServices(services => + { + services.AddSingleton(); + }) + .Build() + .Run(); +``` + +### Custom Middleware + +Add middleware to the `IApplicationBuilder` delegate passed to `Build(...)`: + +```csharp +NanoApiApplication + .ConfigureApp() + .ConfigureServices(services => { /* ... */ }) + .Build(builder => + { + builder.Use((context, next) => + { + context.Response.Headers["MyHeader"] = "MyValue"; + + return next(); + }); + }) + .Run(); +``` + +⚠ Custom middleware is **appended to the end** of Nano's own middleware pipeline — it can't run earlier in the +pipeline than Nano's built-in middleware. + +⚠ Only API and Web applications support this — Console applications ignore the `Build(builder => ...)` delegate +entirely, since there's no HTTP pipeline to add middleware to. + +### Custom Configuration Section + +Define an options model, add a matching section to `appsettings.json`, and bind it with +`AddNanoConfigSection(name, out options)`: + +```csharp +public class MySectionModel +{ + // Properties... +} +``` + +```json +{ + "MySection": { } +} +``` + +```csharp +.ConfigureServices(services => +{ + services.AddNanoConfigSection("MySection", out var options); +}) +``` + +`options` is the bound instance, available immediately for further service registration in the same +`ConfigureServices` call; the section is also registered for standard `IOptions`/`IOptionsMonitor` +injection anywhere else. Binding uses the same validation as every other Nano section — +`ValidateDataAnnotationsRecursively().ValidateOnStart()` — so a `[Required]` property left unset fails at host +startup, not on first use. + +⚠ The section name must actually **exist** in configuration, even if empty (`"MySection": { }`) — an entirely +missing section throws `InvalidOperationException` at startup, it doesn't silently bind an all-defaults instance. + +Section names must not collide with Nano's own built-in sections: `App`, `Logging`, `Data`, `Eventing`, `Storage`. + +--- + +## Nano.App.Api + +`NanoApiApplication` — the ready-to-use API host template. + +### Registration + +```powershell +dotnet add package Nano.App.Api; +``` + +```csharp +NanoApiApplication + .ConfigureApp() + .ConfigureServices(x => + { + // Your services... + }) + .Build() + .Run(); +``` + +### Configuration + +The `App` section defines application-level behavior. + +| Setting | Type | Default | Description | +| ---------------------- | ---------- | ------- | --------------------------------------------------------------- | +| `Version` | string | 1.0.0.0 | Application version identifier. | +| `ShutdownTimeout` | int | 10 | Seconds to wait after SIGTERM before shutting down. | +| `Hosting` | object | default | See [Hosting](#hosting). | +| `HttpPolicyHeaders` | object | default | See [Http Policy Headers](#http-policy-headers). | +| `ResponseCache` | object | null | See [Response Cache](#response-cache). | +| `ResponseCompression` | object | null | See [Response Compression](#response-compression). | +| `Session` | object | null | See [Session](#session). | +| `TimeZone` | object | null | See [TimeZone](#timezone). | +| `Localization` | object | null | See [Localization](#localization). | +| `Documentation` | object | null | Swagger config. See [Documentation](#documentation). | +| `HealthCheck` | object | null | See [Health Checks](#health-checks). | +| `Metrics` | object | null | See [Metrics (OpenTelemetry)](#metrics-opentelemetry). | +| `VirusScan` | object | null | See [Virus Scan](#virus-scan). | +| `ErrorHandling` | object | default | See [Error Handling](#error-handling). | +| `Authentication` | object | default | See [Authentication](#authentication). | +| `Apis` | dictionary | [] | Named Nano API client configurations. See [Nano.App § Api Clients](#api-clients). | + +```json +"App": { + "Version": "1.0.0.0", + "ShutdownTimeout": 10, + "Hosting": { }, + "HttpPolicyHeaders": { }, + "ResponseCache": null, + "ResponseCompression": null, + "Session": null, + "TimeZone": null, + "Localization": null, + "Documentation": null, + "HealthCheck": null, + "VirusScan": null, + "ErrorHandling": { }, + "Authentication": { }, + "Apis": [] +} +``` + +#### Hosting + +How the API is hosted on Kestrel. + +| Setting | Type | Default | Description | +| -------------------- | ------ | ------- | -------------------------------------------------- | +| `Root` | string | api | Root route prefix for application endpoints. | +| `Http` | object | default | See [Http](#http). | +| `Https` | object | null | See [Https](#https). | +| `MultipartLimits` | object | null | See [MultiPart Limits](#multipart-limits). | + +```json +"App": { + "Hosting": { + "Root": "api", + "Http": { }, + "Https": null, + "MultipartLimits": null + } +} +``` + +##### Http + +| Setting | Type | Default | Description | +| ------------------------ | ------ | ------- | ------------------------------------------- | +| `Ports` | array | [] | List of ports for HTTP. | +| `UseHttpsRedirection` | bool | false | Enforce HTTPS redirect for all requests. | + +```json +"App": { + "Hosting": { + "Http": { + "Ports": [], + "UseHttpsRedirection": false + } + } +} +``` + +⚠ At least one HTTP or HTTPS port must be specified. Avoid the default port 80 — it may trigger security +warnings in Kubernetes. + +##### Https + +Requires at least one port plus a certificate path and password. Intended primarily for local development — +`Staging`/`Production` TLS is handled at the gateway/cert-manager level, not via this config. + +| Setting | Type | Default | Description | +| ------------------------- | ------ | ------- | -------------------------------------------- | +| `Ports` | array | [] | List of ports for HTTPS. | +| `UseHttpsRequired` | bool | false | Enforce HTTPS for all requests. | +| `Certificate.Path` | string | null | Required. File path to the certificate. | +| `Certificate.Password` | string | null | Required. Password for the certificate. | + +```json +"App": { + "Hosting": { + "Http": { "UseHttpsRedirection": true }, + "Https": { + "Ports": [4443], + "Certificate": { + "Path": "/root/.dotnet/https/localhost.pfx", + "Password": "password" + }, + "UseHttpsRequired": true + } + } +} +``` + +⚠ Avoid the default HTTPS port 443 — it may trigger security warnings in Kubernetes. Configure this only in +`appsettings.Development.json`. + +##### Routing + +No configuration — routing is fully automatic. Routes are derived from the base controller a concrete controller +derives from; API versioning is integrated into the route automatically. All routes are normalized to lowercase. + +##### MultiPart Limits + +| Setting | Type | Default | Description | +| ---------------------- | ----- | -------- | ------------------------------------------------- | +| `MaxUploadBytes` | int | 33554432 | Maximum upload size in bytes (default 32 MB). | +| `KeepAliveTimeout` | int | 00:02:10 | Timeout for slow uploads. | + +```json +"App": { + "Hosting": { + "MultipartLimits": { + "MaxUploadBytes": 33554432, + "KeepAliveTimeout": 130 + } + } +} +``` + +⚠ Leaving this `null` allows unlimited uploads — fine if limits are enforced at the orchestration level, +otherwise set explicit limits. + +#### Http Policy Headers + +Parent config object for headers such as HSTS, XSS protection, CSP, CORS, and other browser-security policies. + +| Setting | Type | Default | Description | +| ---------------------- | ------ | ------- | ---------------------------------------------------- | +| `ContentType` | object | null | See [Content Type Options](#content-type-options). | +| `ReferrerPolicy` | object | null | See [Referrer Policy](#referrer-policy). | +| `FrameOptions` | object | null | See [Frame Options](#frame-options). | +| `XssProtection` | object | null | See [Xss Protection](#xss-protection). | +| `Csp` | object | null | See [Content Security Policy (CSP)](#content-security-policy-csp). | +| `Cors` | object | null | See [Cors](#cors). | +| `Hsts` | object | null | See [Strict Transport Security (Hsts)](#strict-transport-security-hsts). | +| `Robots` | object | null | See [Robots](#robots). | +| `ForwardedHeaders` | object | null | See [Forwarded Headers](#forwarded-headers). | + +```json +"App": { + "HttpPolicyHeaders": { + "ContentType": null, + "ReferrerPolicy": null, + "FrameOptions": null, + "XssProtection": null, + "Csp": null, + "Cors": null, + "Hsts": null, + "Robots": null, + "ForwardedHeaders": null + } +} +``` + +##### Content Type Options + +Sets the `X-Content-Type-Options` response header to prevent MIME type sniffing. + +| Setting | Type | Default | Description | +| ------------- | ---- | ------- | ---------------------------------------- | +| `NoSniff` | bool | true | If true, prevents MIME type sniffing. ⭐ recommended: `true`. | + +```json +"App": { + "HttpPolicyHeaders": { + "ContentType": { "NoSniff": true } + } +} +``` + +##### Referrer Policy + +Sets the `Referrer-Policy` response header, controlling how much referrer information is sent with requests. + +| Setting | Type | Default | Description | +| --------------------------- | ---- | -------- | --------------------------------- | +| `ReferrerPolicyHeader` | enum | Disabled | See policy values below. | + +```json +"App": { + "HttpPolicyHeaders": { + "ReferrerPolicy": { "ReferrerPolicyHeader": "Disabled" } + } +} +``` + +| Policy | Description | +| ---------------------------------- | ------------ | +| `Disabled` | Header not set. | +| `NoReferrer` | No referrer information sent. | +| `NoReferrerWhenDowngrade` | Full referrer unless HTTPS → HTTP. | +| `SameOrigin` ⭐ | Full referrer for same-origin, none for cross-origin. | +| `Origin` | Only origin (no path/query) sent, always. | +| `StrictOrigin` | Origin only, and never HTTPS → HTTP. | +| `OriginWhenCrossOrigin` | Full for same-origin, origin-only for cross-origin. | +| `StrictOriginWhenCrossOrigin` | Full for same-origin, origin-only cross-origin, none HTTPS → HTTP. | +| `UnsafeUrl` | Full referrer always, including HTTPS → HTTP. Unsafe. | + +The `[ReferrerPolicy]` action/controller attribute overrides the global setting per endpoint. + +##### Frame Options + +Sets `X-Frame-Options`, guarding against clickjacking by controlling `