Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions .claude/skills/nano-add-authentication-jwt/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,26 @@ 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 (see
`Nano.Lessons/Api.Auth.External.Microsoft`). If the request names Microsoft specifically, use
that skill instead of configuring `Jwt.ExternalLogins.Microsoft` by hand here.
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
Expand Down
79 changes: 52 additions & 27 deletions .claude/skills/nano-add-authentication-microsoft/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ convention for those the way this skill does for Microsoft.
| 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 and
what `Nano.Lessons/Api.Auth.External.Microsoft` and `Nano.Templates/Api.Admin` both use. Whatever
is chosen, remind the user that the client-side code that starts the sign-in (MSAL.js or
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.

Expand All @@ -67,20 +67,35 @@ Base `appsettings.json`, nested under the existing `Jwt` block:
"TenantId": null,
"ClientId": null,
"ClientSecret": null,
"Scopes": [ "openid", "profile", "email" ]
"Scopes": [ "openid", "profile", "email", "offline_access" ]
}
}
```

`appsettings.Development.json` — same shape, still `null`. **Do not hardcode real values here**,
unlike the shared JWT Development key pair — a Microsoft app registration is tied to a real Azure
tenant, not a throwaway pair everyone in the codebase can share. The developer fills these in
locally themselves, after creating 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). No Graph API
permission is needed beyond the default — `openid`/`profile`/`email` only affect what lands in the
`id_token`, not access to any resource.
`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<TFlow>`'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
Expand Down Expand Up @@ -237,20 +252,6 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor
key: client-secret
```

## Reference implementation

`Nano.Lessons/Api.Auth.External.Microsoft` is this exact setup end-to-end (transient login, no
Identity) — its `.github/workflows/build-and-deploy.yml`, `.kubernetes/auth-microsoft-secret.yaml`,
and `.kubernetes/deployment.yaml` are the working, tested version of everything above. When in
doubt about exact formatting or step ordering, diff against that lesson rather than guessing.

One difference: the lesson (and `Nano.Templates/Api.Admin`) hardcode `AzureADMyOrg` directly rather
than reading `$env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE`, and their Kubernetes secret still reads
`%AZURE_TENANT_ID%` rather than `%AUTH_MICROSOFT_TENANT_ID%` — both are intentionally left as the
simpler, single-tenant-only version, since neither needs broader sign-in. Don't "fix" them to match
this skill unless asked; treat this skill's parameterized version as what to scaffold for a *new*
app whose audience was actually asked about in step 5.

## After making the change

- Show the user every file touched, grouped by concern: appsettings per environment, and — if
Expand All @@ -264,3 +265,27 @@ app whose audience was actually asked about in step 5.
- 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.
8 changes: 4 additions & 4 deletions .claude/skills/nano-add-custom-endpoint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(...)]`
Expand Down
15 changes: 6 additions & 9 deletions .claude/skills/nano-add-data-provider/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,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
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/nano-add-entity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ public class <Entity>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.

Expand Down
110 changes: 110 additions & 0 deletions .claude/skills/nano-add-event-handler/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: nano-add-event-handler
description: Add an event handler to a Nano application - a class deriving BaseEventHandler<TEvent> 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<TEvent>` 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<TProvider>()` (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<MyEvent>
{
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<TEvent>` 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<MyEvent>
{
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.
Loading
Loading