# fullstackhero — .NET 10 Starter Kit (full documentation) > Free, MIT-licensed .NET 10 starter kit with a React admin + dashboard. Modular monolith, vertical slice, multitenant, with ten ready-to-ship modules. Generated from the canonical docs site at https://fullstackhero.net. Source repository: https://github.com/fullstackhero/dotnet-starter-kit License: MIT Pages: 92 # Overview > How fullstackhero is wired for AI coding tools — AGENTS.md, the .agents folder, rules, skills, and workflows — and how to build with Claude Code. Source: https://fullstackhero.net/docs/ai-development/ fullstackhero ships **agent-ready**. The conventions, footguns, and step-by-step recipes that normally live in a senior engineer's head are written down in a form AI coding tools read on demand — so an agent scaffolds a feature, adds a module, or writes a migration the *same* way you would, without breaking the architecture. Two pieces make this work: - **AGENTS.md** — the single, tool-neutral guide at the repo root. CLAUDE.md and GEMINI.md are thin bridges that import it, so every tool reads the same source of truth. - **.agents/** — a folder of **rules** (conventions, read on demand), **skills** (task recipes), and **workflows** (review/orchestration playbooks). Everything here is plain Markdown keyed off the open AGENTS.md convention. It works with Claude Code, Gemini CLI, Cursor, Codex, and anything else that reads `AGENTS.md` — no vendor lock-in. --- # AGENTS.md & the .agents Folder > The canonical AGENTS.md guide, the CLAUDE.md/GEMINI.md bridges, the .agents folder structure, and the on-demand rule files that encode every convention. Source: https://fullstackhero.net/docs/ai-development/agents-md/ ## AGENTS.md — the canonical guide **AGENTS.md** at the repo root is the single source of truth for AI tools. It's deliberately lean: a repo map, the tech stack, full-stack build/run commands, a short list of "golden rules" that must never be broken, and an index pointing into `.agents/rules/`. Conventions are edited *there* — not in the bridges. **CLAUDE.md** (Claude Code) and **GEMINI.md** (Gemini CLI) are one-line bridges that import it: ```md title="CLAUDE.md" # Claude Code The canonical project guide is **`AGENTS.md`** (tool-neutral). It is imported below; edit conventions there, not here. @AGENTS.md ``` So every tool — and every contributor — reads the same guide, and there's exactly one place to update. Only the root guide loads into the agent's context on every session. Detailed conventions live in `.agents/rules/` and are pulled in **on demand**, when the agent is actually working in that area. That keeps each session cheap while still having deep guidance available the moment it's needed. ## The `.agents/` folder ``` AGENTS.md ← canonical guide (CLAUDE.md / GEMINI.md import it) .agents/ ├── rules/ ← conventions & footguns (read on demand) │ ├── architecture.md module boundaries, registration, middleware order │ ├── api-conventions.md endpoints, CQRS, validation, exceptions, permissions │ ├── database.md EF Core, migrations, tenant isolation, query filters │ ├── eventing.md domain vs integration events, Outbox/Inbox │ ├── caching.md jobs.md resilience.md storage.md │ ├── security.md CORS, security headers, rate limiting, idempotency, quotas │ ├── realtime.md logging.md testing.md integration-testing.md │ ├── buildingblocks-protection.md read before touching src/BuildingBlocks (it's protected) │ ├── modules/ one file per module (identity, multitenancy, chat, files, │ │ webhooks, auditing, billing, catalog, tickets, notifications) │ └── frontend/ shared.md · admin.md · dashboard.md (the two React apps) ├── skills/ ← task recipes (see Skills & Workflows) │ └── {skill}/SKILL.md └── workflows/ ← review / orchestration playbooks └── {workflow}.md ``` ## Rules — conventions, read on demand A **rule** is knowledge the agent reads before working in an area — the conventions and the *footguns* that are easy to get wrong. Rules don't restate what the code or tests already enforce; they point at the enforcement and capture the non-obvious. The root `AGENTS.md` carries the index, e.g. *"before EF or migration work, read `database.md`."* | Working on… | Rule | |---|---| | Module structure, boundaries, registration, middleware order | `architecture.md` | | Endpoints, CQRS, validation, exceptions, permissions | `api-conventions.md` | | EF Core, entities, migrations, tenant isolation | `database.md` | | Cross-module events, Outbox/Inbox, idempotent handlers | `eventing.md` | | Caching · jobs · HTTP resilience · file storage | `caching.md` · `jobs.md` · `resilience.md` · `storage.md` | | CORS, security headers, rate limiting, idempotency, quotas | `security.md` | | SignalR / SSE · logging & OpenTelemetry | `realtime.md` · `logging.md` | | Unit tests · integration tests (Testcontainers) | `testing.md` · `integration-testing.md` | | Modifying `src/BuildingBlocks` (protected — read first) | `buildingblocks-protection.md` | | A specific module's quirks | `modules/{module}.md` | | Any React work · the operator app · the tenant app | `frontend/shared.md` · `frontend/admin.md` · `frontend/dashboard.md` | A few examples of the kind of footgun these capture: registering a module touches **four** places (Mediator assemblies + module array, in *both* the API and DbMigrator hosts); tenant isolation is default-on via `BaseDbContext`; new entity ids use `Guid.CreateVersion7()`; log messages must be structured (no string interpolation). Each is one read away when the agent needs it. Next: the **[Skills & Workflows](/docs/ai-development/skills-and-workflows/)** that turn these conventions into repeatable actions. --- # Developing with Claude Code > A practical guide to building on fullstackhero with Claude Code — how it loads AGENTS.md, invokes skills, runs review workflows, and the build/test loop to follow. Source: https://fullstackhero.net/docs/ai-development/claude-code/ [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) is Anthropic's agentic CLI. Because this repo is wired with `AGENTS.md` and `.agents/`, Claude works *on-convention* out of the box — it scaffolds slices, wires modules, and writes migrations the way the kit expects. ## 1. Install & open ```bash npm install -g @anthropic-ai/claude-code # see the official docs for native installers cd dotnet-starter-kit claude ``` On startup, Claude Code reads `CLAUDE.md`, which imports `AGENTS.md` — so the repo map, golden rules, and the rules index are in context immediately. The detailed rule files load **on demand** as Claude works. Don't paste conventions into the chat — they're already in `AGENTS.md` and `.agents/rules/`. If something is wrong or missing, fix the rule file; every session (and every contributor) picks it up. ## 2. Build features by describing them You don't invoke skills by name — describe the task and the matching skill activates: | You say… | Skill that runs | |---|---| | "Add a `CreateInvoice` feature to Billing" | `add-feature` | | "Scaffold a `Reviews` module" | `add-module` (incl. the four-place registration) | | "Add a brands list+create page to the dashboard" | `add-react-page` | | "Build assignee filtering end-to-end for Tickets" | `add-full-slice` | | "Create a migration for the new column" | `create-migration` | | "Publish an `InvoicePaid` event that Notifications reacts to" | `add-integration-event` | Each skill ends by building/testing, so you get working, on-convention code rather than a sketch. ## 3. Review before you commit Two read-only workflows act as guardrails — ask for them by intent: ```text "Review my changes" → code-reviewer (diff vs. conventions, structured report) "Check architecture" → architecture-guard (boundaries, BuildingBlocks, arch tests) ``` If the Roslyn navigator MCP is configured (the `dotnet-claude-kit` plugin), the reviewer also runs `detect_antipatterns` and `get_diagnostics` for machine-found issues. ## 4. The build/test loop ```bash dotnet build src/FSH.Starter.slnx # 0 warnings — the repo runs TreatWarningsAsErrors dotnet test src/FSH.Starter.slnx # unit + architecture + integration ``` `Integration.Tests` spins up real Postgres, Valkey, and MinIO via Testcontainers. If Docker isn't running you'll see `DockerUnavailableException` — that's environmental, not a code regression. Run the unit projects (e.g. `dotnet test src/Tests/Catalog.Tests`) to validate logic without Docker. To see it all running, the Aspire AppHost brings up the whole stack — Postgres, Valkey, MinIO, the migrator, the API, and both React apps — with one command: ```bash dotnet run --project src/Host/FSH.Starter.AppHost ``` ## 5. Golden rules worth remembering Claude follows these from `AGENTS.md`; knowing them helps you review its work: - **Module boundaries** — modules talk only through `*.Contracts`. Enforced by `Architecture.Tests`. - **Registering a module touches four places** — Mediator assemblies + module array, in *both* the API and DbMigrator hosts. A missing Mediator marker means handlers silently don't run. - **Tenant isolation is default-on** via `BaseDbContext`; opt out only via `IGlobalEntity`. - **Every command + paginated query needs a validator**; handlers are `public sealed`, return `ValueTask`. - **Structured logging only** — no string interpolation in log messages. - **Don't modify `src/BuildingBlocks`** without intent — it's shared by every module. ## 6. Keep the guidance sharp The `.agents/` set is living documentation. When you establish a new convention or hit a new footgun, add or update a rule (or a skill), commit it, and the whole team's agents improve. The [AGENTS.md & .agents/](/docs/ai-development/agents-md/) page explains the structure; [Skills & Workflows](/docs/ai-development/skills-and-workflows/) shows how to extend the set. --- # Skills & Workflows > The catalog of .agents skills (task recipes like add-feature, add-module, create-migration) and workflows (review and orchestration playbooks) that ship… Source: https://fullstackhero.net/docs/ai-development/skills-and-workflows/ Rules are knowledge; **skills** and **workflows** are action. - A **skill** (`.agents/skills/{name}/SKILL.md`) is a *verb* — a repeatable, footgun-aware recipe for one task that ends in a green build/test. The agent reaches for it when your request matches its description. - A **workflow** (`.agents/workflows/{name}.md`) is *orchestration* — it sequences skills and adds review/verification. Workflows delegate the code recipe to the skills rather than duplicating them. ## Skills ### Scaffolders — go from idea to working code | Skill | What it does | |---|---| | `add-feature` | A backend vertical slice: Command/Query in Contracts → handler (injects the module `DbContext`) → validator → endpoint → wired in the module. | | `add-entity` | A domain aggregate + EF configuration + migration, with the soft-delete / tenant / value-generation conventions baked in. | | `add-module` | A whole new bounded context — both projects, `IModule`, `BaseDbContext`, permissions, and registration in **all four** sites. | | `add-react-page` | A frontend slice (admin or dashboard): API module, page, lazy route, permission gate, Playwright test. | | `add-full-slice` | End-to-end: the backend slice **and** the React page wired to it — the kit's accelerator. | ### Ops — footgun-heavy operations | Skill | What it does | |---|---| | `create-migration` | The exact EF Core migration command (`--project`/`--startup-project`/`--context`/`--output-dir`), build-first to protect the snapshot, review the SQL, apply via the DbMigrator. | | `add-integration-event` | Publish a cross-module event through the **Outbox** and handle it idempotently — including background tenant-context restoration. | | `add-permission` | A new permission end-to-end: server constant + endpoint gate, and (admin app) the mirrored catalog entry + route guard. | ### Reference — guardrails | Skill | What it covers | |---|---| | `query-patterns` | Read queries the FSH way — paginated `PagedResponse` via DbContext LINQ; when to use a Specification. | | `testing-guide` | Test conventions — xUnit + Shouldly + NSubstitute + AutoFixture, naming, and the integration-test gotchas. | | `mediator-reference` | This kit uses the **Mediator** source generator, *not* MediatR — the interface map and the registration sites. | ## Workflows | Workflow | Role | |---|---| | `feature-scaffolder` | Orchestrates feature delivery — sequences `add-entity` → `add-feature` → `add-react-page`/`add-full-slice` → tests → verify. | | `module-creator` | Orchestrates a full module bring-up and verifies it actually *loads* (the four-place registration footgun). | | `migration-helper` | The facts + troubleshooting around EF migrations; delegates the recipe to `create-migration`. | | `code-reviewer` | Reviews the current diff against the conventions and emits a structured report (read-only). | | `architecture-guard` | Verifies architectural integrity — module boundaries, BuildingBlocks protection, the architecture-test suite (read-only). | You don't memorize skill names. Describe the task — *"add a CreateInvoice feature to Billing"* — and the matching skill activates automatically. See Developing with Claude Code. ## Extending the set These are plain Markdown — add your own. A good **skill** is a verb (a repeatable task that can self-verify with a build/test); a good **rule** is a noun (a convention you read). If you find yourself writing a "reference" or "guide" skill, it's probably a rule. Keep skills pointing *at* the rules rather than restating them, so there's a single source of truth. --- # Overview > Modular monolith, vertical slice architecture, module boundaries, and the composition root. Source: https://fullstackhero.net/docs/architecture/ How fullstackhero is put together — the modular monolith, vertical slice architecture, and how modules talk to each other through contracts. --- # Dependency injection & module loading > How AddHeroPlatform, the module loader, the Mediator source generator, and FluentValidation auto-registration compose into a working host. Source: https://fullstackhero.net/docs/architecture/dependency-injection/ fullstackhero's composition root is `src/Host/FSH.Starter.Api/Program.cs`, and it's short. Condensed: ```csharp // src/Host/FSH.Starter.Api/Program.cs (condensed) var builder = WebApplication.CreateBuilder(args); builder.Services.AddMediator(o => { o.ServiceLifetime = ServiceLifetime.Scoped; o.Assemblies = [ typeof(GenerateTokenCommand), typeof(GenerateTokenCommandHandler), // Identity — Contracts + runtime typeof(GetTenantStatusQuery), typeof(GetTenantStatusQueryHandler), // Multitenancy — Contracts + runtime /* …two marker types per module, for all ten modules… */ ]; }); var moduleAssemblies = new Assembly[] { typeof(IdentityModule).Assembly, typeof(MultitenancyModule).Assembly, /* …all ten module runtime assemblies… */ }; builder.AddHeroPlatform(o => { o.EnableCaching = true; o.EnableJobs = true; /* … */ }); builder.AddModules(moduleAssemblies); var app = builder.Build(); app.UseHeroMultiTenantDatabases(); // Finbuckle UseMultiTenant() — before the rest of the pipeline app.UseHeroPlatform(p => { p.MapModules = true; /* … */ }); await app.RunAsync(); ``` That's the whole composition root. Everything underneath is the platform's job. This page is about what those calls actually do, the rules they impose, and the specific wiring failure that catches every team at least once. ## The four calls 1. **`builder.Services.AddMediator(o => ...)`** — registers Mediator 3's source-generated dispatch. `o.Assemblies` is a list of *marker types*: the generator scans the assembly each type lives in for `ICommand` / `IQuery` / handler implementations. Every module contributes **two** markers — one type from its `.Contracts` assembly and one from its runtime assembly. Handlers are registered with `ServiceLifetime.Scoped`. 2. **`builder.AddHeroPlatform(o => ...)`** — registers the cross-cutting platform: logging, OpenTelemetry, OpenAPI, CORS, security headers, API versioning, exception handling, the validation pipeline behavior, and any optional sub-blocks (caching, jobs, mailing, feature flags, idempotency, SSE, realtime, quotas) you opted into. 3. **`builder.AddModules(moduleAssemblies)`** — runs the `ModuleLoader`. It registers every FluentValidation validator in the supplied assemblies, discovers the assembly-level `[FshModule]` attributes, sorts modules by order, and invokes each one's `ConfigureServices(IHostApplicationBuilder)`. 4. **`app.UseHeroPlatform(p => ...)`** — wires the middleware pipeline in the right order, invoking each module's `ConfigureMiddleware` (right after authentication) and `MapEndpoints` (after authorization) in module order. ## The IModule contract Every module implements `IModule` and declares itself with an **assembly-level** `[FshModule]` attribute (positional: module type, then order): ```csharp // BuildingBlocks/Web/Modules/IModule.cs public interface IModule { void ConfigureServices(IHostApplicationBuilder builder); void MapEndpoints(IEndpointRouteBuilder endpoints); void ConfigureMiddleware(IApplicationBuilder app) { } // optional — default no-op } ``` ```csharp // Modules.Identity/AssemblyInfo.cs [assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)] ``` ```csharp // Modules.Identity/IdentityModule.cs (condensed) public class IdentityModule : IModule { public void ConfigureServices(IHostApplicationBuilder builder) { PermissionConstants.Register(IdentityPermissions.All); builder.Services.AddScoped(); builder.Services.AddHeroDbContext(); // ... } public void MapEndpoints(IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("api/v{version:apiVersion}/identity") .WithTags("Identity") .WithApiVersionSet(apiVersionSet); group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth"); group.MapRegisterUserEndpoint(); // ... the rest of the module's endpoints } } ``` The `ModuleLoader` runs `ConfigureServices` for **every** module in order, then later runs `ConfigureMiddleware` and `MapEndpoints` for every module in the same order. This two-phase wiring means a module can resolve dependencies registered by an earlier-order module during its own service registration. ## Mediator 3 — source-generated dispatch Mediator 3 (the `martinothamar/Mediator` library, not to be confused with MediatR) is source-generated. At compile time, its generator walks `MediatorOptions.Assemblies`, finds every `ICommandHandler` and `IQueryHandler`, and emits a static dispatch table. **Zero runtime reflection.** The catch: the generator only scans assemblies that appear in `o.Assemblies`. A module's commands live in its `.Contracts` assembly and its handlers live in its runtime assembly — so each module needs **both** markers in the list. Leave one out and nothing breaks at build time; the handler is just missing from the dispatch table, and `mediator.Send(...)` fails at runtime. The registration sites and the two-marker rule are documented in `.agents/rules/architecture.md` and the `mediator-reference` skill. ## FluentValidation auto-registration `ModuleLoader.AddModules` calls `services.AddValidatorsFromAssemblies(moduleAssemblies)`, so every `AbstractValidator` in any module is registered automatically. The Mediator pipeline behavior `ValidationBehavior` (registered by `AddHeroPlatform`) runs them before every command handler. What this means: drop a `MyCommandValidator : AbstractValidator` next to the handler, and validation runs on every `mediator.Send(new MyCommand(...))`. No registration line in your module's `ConfigureServices`. ## Three lifetimes, applied consistently The kit uses standard `IServiceCollection` lifetimes: | Lifetime | Use for | Example | |---|---|---| | Singleton | Stateless / app-wide / thread-safe | `IEventSerializer`, `ITenantInitialPasswordBuffer`, metrics/meter types | | Scoped | Per-request DbContext, per-request user, Mediator handlers | `DbContext`, `ICurrentUser`, `ITokenService`, `IQuotaService`, command/query handlers (`o.ServiceLifetime = ServiceLifetime.Scoped`) | | Transient | Stateless services that aren't expensive to construct | `IUserService` and the focused user sub-services, `IConnectionStringValidator` | Two things to watch out for: - **Hangfire jobs get a new scope per execution.** `FshJobActivator` creates an `IServiceScope` per job. So a scoped service injected into a job sees a fresh instance for each run. Don't try to share state via scoped services across job invocations; use Valkey or the database. - **Integration event handlers run in their own scope, not the publisher's.** Handlers publish to the **outbox** (same transaction as the business write); `OutboxDispatcherHostedService` picks the row up asynchronously and the event bus creates a *fresh* DI scope per event before resolving handlers. Don't expect to share a DbContext or transaction with the publisher — idempotency comes from the Inbox (`{EventId, HandlerName}` dedupe), not shared state. ## Permission registration `PermissionConstants` is a static registry. Each module calls it during `ConfigureServices`: ```csharp PermissionConstants.Register(IdentityPermissions.All); PermissionConstants.Register(CatalogPermissions.All); PermissionConstants.Register(TicketsPermissions.All); // ... ``` The registration is global, deduped by `Name`, and additive. There's no removal API — once registered, a permission is part of the runtime for the process lifetime. The Identity module's `RequiredPermissionAuthorizationHandler` reads the permission from the endpoint metadata and checks it against the caller's claims. The kit's `RequiredPermissionAttribute` and `.RequirePermission()` fluent helper both produce that same metadata. `RequiredPermissionAttribute` (in the Shared block) implements `IRequiredPermissionMetadata` — that interface is what the authorization handler looks up on the endpoint. If a copy of the attribute lands in another assembly **without implementing the interface**, every `.RequirePermission()` call silently stops gating. This is a runtime no-op, not a build break — and it's the single nastiest failure mode in the kit's auth pipeline. The handler carries a source comment warning about exactly this. ## The four wire points when you add a new module Adding a new module touches four lists across **two host files**. Miss any one of them and it fails *silently*: | # | Place | File | Symptom if missed | |---|---|---|---| | 1 | Mediator `o.Assemblies` — two markers (a Contracts type **and** the module type) | `src/Host/FSH.Starter.Api/Program.cs` | Handlers silently undiscovered | | 2 | `moduleAssemblies` array | `src/Host/FSH.Starter.Api/Program.cs` | Module never loaded | | 3 | Mediator assemblies (same pair) | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module | | 4 | Module assemblies array | `src/Host/FSH.Starter.DbMigrator/Program.cs` | Migrate/seed misses the module | The DbMigrator pair is the one everyone forgets: it mirrors the API's registration because some module DbInitializers depend on services the Mediator pipeline builds. Forgetting #1 is the nastiest failure mode — the host boots, the module's services register, the endpoints map, but `mediator.Send(new MyCommand())` fails at runtime because the dispatch table doesn't include the handler. (You'll also add the new `*.Contracts` and runtime projects to `FSH.Starter.slnx`, but the compiler catches that one for you.) ## The middleware pipeline order `app.UseHeroMultiTenantDatabases()` runs first, in `Program.cs` before `UseHeroPlatform` — it's Finbuckle's `UseMultiTenant()`, so tenant resolution happens before everything below (including authentication). Then `UseHeroPlatform` wires the pipeline in a deliberate order: ``` 1. UseExceptionHandler → RFC 9457 ProblemDetails 2. UseResponseCompression 3. UseCors ← before HTTPS redirect (preflight) 4. UseHttpsRedirection 5. Security headers 6. UseStaticFiles (optional) 7. Hangfire dashboard (if jobs enabled) 8. UseRouting 9. OpenAPI + Scalar 10. UseAuthentication 11. Per-module ConfigureMiddleware ← multi-tenant root override etc. 12. UseRateLimiter 13. Quota enforcement (if quotas enabled) 14. UseAuthorization 15. Per-module MapEndpoints 16. Health, SSE, SignalR 17. CurrentUserMiddleware ← last, so authorization already done ``` Three ordering rules are unusual and important: - **Tenant resolution before authentication.** Finbuckle's strategy chain sees an anonymous `User`, which is why claim-aware tenant logic lives in post-auth module middleware — see the [multitenancy deep dive](/docs/architecture/multitenancy-deep-dive/). - **CORS before HTTPS redirect.** Preflight `OPTIONS` requests can't follow redirects per the Fetch spec. - **Per-module middleware after authentication.** `UseModuleMiddlewares` runs each module's `ConfigureMiddleware` right after `UseAuthentication`, so module middleware (like Multitenancy's root-operator override) can read claims. `CurrentUserMiddleware` runs last: it populates `ICurrentUser` from claims for the endpoint to consume. Anything reading `ICurrentUser` earlier in the chain sees nothing — middleware that needs the user reads claims directly. ## Related - [Web building block](/docs/building-blocks/web/) — the composition + middleware code. - [Vertical Slice](/docs/architecture/vertical-slice/) — what a handler looks like. - [Modular monolith](/docs/architecture/modular-monolith/) — the module ordering rules. --- # Modular monolith > How fullstackhero composes ten modules into a single deployable process with hard module boundaries enforced by architecture tests. Source: https://fullstackhero.net/docs/architecture/modular-monolith/ fullstackhero is a **modular monolith**. Ten modules — Identity, Multitenancy, Auditing, Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets — live in one repository, build one set of containers, and deploy as one process. Each is a bounded context with its own `DbContext`, its own feature folders, and a single one-way dependency channel (its `*.Contracts` assembly) through which the rest of the kit talks to it. A modular monolith is its own architecture. You ship one process, one deploy, one set of logs, one set of traces — which is faster than running ten services for most teams. When a module hits a true horizontal-scaling need, the boundaries you've already drawn make extraction a refactor, not a rewrite. ## The shape ``` src/ ├── BuildingBlocks/ Shared infrastructure (11 libraries) │ ├── Core, Persistence, Web, Shared │ ├── Caching, Eventing, Eventing.Abstractions │ └── Jobs, Mailing, Storage, Quota │ ├── Modules/ Ten bounded contexts │ ├── Identity/ │ │ ├── Modules.Identity/ Runtime — endpoints, handlers, domain │ │ └── Modules.Identity.Contracts/ Public surface — commands, queries, DTOs, events │ ├── Multitenancy/ (+ Contracts) │ ├── Auditing/ (+ Contracts) │ ├── Files/ (+ Contracts) │ ├── Chat/ (+ Contracts) │ ├── Notifications/ (+ Contracts) │ ├── Webhooks/ (+ Contracts) │ ├── Billing/ (+ Contracts) │ ├── Catalog/ (+ Contracts) │ └── Tickets/ (+ Contracts) │ ├── Host/ Composition root + orchestrator │ ├── FSH.Starter.Api/ The API process │ ├── FSH.Starter.AppHost/ .NET Aspire orchestrator │ ├── FSH.Starter.DbMigrator/ Migrations + demo seeder CLI │ └── FSH.Starter.Migrations.PostgreSQL/ │ └── Tools/CLI/ The `fsh` CLI (Spectre.Console) ``` Every module follows the same shape: a runtime project that does the work, a contracts project that defines its public surface, and (optionally) a migrations project for its EF Core schema. ## The boundary rule Modules talk to each other **only through `*.Contracts` assemblies**. | Allowed | Not allowed | |---|---| | `Modules.Chat.dll` references `Modules.Notifications.Contracts.dll` | `Modules.Chat.dll` references `Modules.Notifications.dll` | | `Modules.Identity.dll` references `BuildingBlocks/*` | `Modules.Identity.dll` references any other module's runtime | | Any module's `Contracts.dll` references `Eventing.Abstractions.dll` | A `Contracts.dll` references `Eventing` (the runtime) or EF Core | The cardinal rule: **runtime modules never reference each other.** If module A needs something from module B, it asks module B's contracts — which is a thin layer of commands, queries, events, and DTOs. These rules aren't conventions; they're enforced by `src/Tests/Architecture.Tests/`. The boundary test scans every module runtime `.csproj` for forbidden project references: ```csharp // Architecture.Tests/ModuleArchitectureTests.cs (condensed) [Fact] public void Modules_Should_Not_Depend_On_Other_Modules() { // for every Modules.*.csproj (excluding *.Contracts), assert no ProjectReference // points at another module's runtime project isSelfReference.ShouldBeTrue( $"Module runtime project '{currentName}' must not reference other module runtime project '{referencedName}'. " + "Only contracts or building block projects are allowed."); } ``` A second layer of [NetArchTest](https://github.com/BenMorris/NetArchTest) tests guards the finer rules — Contracts assemblies can't depend on EF Core, FluentValidation, or Hangfire; domain types can't reach into persistence. Break a rule and the test suite fails. ## How modules talk to each other Three patterns, all one-way and contract-only: ### 1. Request a service (rare) Some contracts expose service interfaces — `ITokenService`, `IUserService` (in `Modules.Identity.Contracts`) — that downstream modules can resolve via DI without referencing the runtime. The interface lives in `*.Contracts`; the implementation lives in the runtime; consumers depend only on the contract. (`ICurrentUser` works the same way, but lives one level down in `BuildingBlocks/Core`; Identity registers the implementation.) ### 2. Domain events (in-module) Domain events are private to the aggregate's module. The `DomainEventsInterceptor` (from the Persistence block) dispatches them through Mediator after SaveChanges. Handlers live in the same module; cross-module dependency is zero. ### 3. Integration events (cross-module) The canonical pattern. A module writes an `IIntegrationEvent` to the **outbox** (`IOutboxStore.AddAsync`, committed with the business write); the kit's outbox dispatcher publishes it asynchronously via `IEventBus`; any module that implements `IIntegrationEventHandler` receives a copy, with inbox-backed idempotency. Examples in the kit: - **Chat → Notifications**: `MentionedInChannelIntegrationEvent` → inbox row + SignalR push. - **Any module → Webhooks**: open-generic `WebhookFanoutHandler` fans every event to subscribed tenants. - **Identity → outbox**: `UserRegisteredIntegrationEvent`, `TokenGeneratedIntegrationEvent` for downstream consumers. The receiver doesn't know the producer. The producer doesn't know the receivers. That's the discipline. ## Module load order Each module runtime carries an assembly-level attribute — `[assembly: FshModule(typeof(IdentityModule), 100)]`. Lower numbers run their `ConfigureServices` and `MapEndpoints` first. The ordering matters for two specific cases: - **Cross-module integration-event handlers.** Notifications (order `750`) must load before Chat (order `800`) because Notifications registers handlers for Chat's `MentionedInChannelIntegrationEvent`. If Chat loaded first, the handler wouldn't be wired when the first mention event fires. - **`ICurrentUser` availability.** Identity (order `100`) loads first so every later module sees the JWT pipeline and `ICurrentUser` registered. | Order | Module | |---|---| | 100 | Identity | | 200 | Multitenancy | | 300 | Auditing | | 350 | Files | | 400 | Webhooks | | 500 | Billing | | 600 | Catalog | | 700 | Tickets | | 750 | Notifications | | 800 | Chat | The `ModuleLoader` (in `BuildingBlocks/Web`) runs each module's `ConfigureServices` in order, then later runs `ConfigureMiddleware` and `MapEndpoints` in the same order. ## When a module needs to scale out The boundaries you've already drawn are the contract for extraction: 1. The module's runtime moves into its own service. 2. Its `*.Contracts` assembly is shared (either as a NuGet package or duplicated). 3. The cross-module communication switches from in-process Mediator (for service calls) and `InMemoryEventBus` (for events) to HTTP / RabbitMQ. 4. Consumers don't change — they still publish via `IEventBus`, still resolve services via DI. Only the transport behind them changes. The kit ships `RabbitMqEventBus` as the cross-process implementation. Wire it in `EventingOptions:Provider = "RabbitMQ"` and you can run two halves of the monolith as two processes while keeping the surface identical. You almost certainly don't need this for a long time. The point is that you don't have to commit to it on day one. ## Why not microservices on day one? Two reasons. **Operational cost.** Ten services means ten deploys, ten log streams, ten sets of dashboards, ten failure modes when one service is down. A modular monolith collapses that to one — and you get genuine isolation through your code structure, not your network topology. **Wrong boundaries.** When you're shipping the first version of a SaaS, you don't yet know which boundaries are stable. Drawing service lines now means committing to API contracts that are about to change. A modular monolith lets you redraw boundaries in a refactor, not a coordinated multi-service deploy. When a module's traffic pattern, scaling needs, or team ownership genuinely diverges from the rest — extract it. Until then, keep them on one box. ## Related - [Vertical Slice Architecture](/docs/architecture/vertical-slice/) — what each module looks like internally. - [Modules overview](/docs/modules/) — the ten that ship in v10. - [Eventing.Abstractions](/docs/building-blocks/eventing-abstractions/) — the contract for cross-module talk. - [Web building block](/docs/building-blocks/web/) — the module loader. --- # Multitenancy deep dive > How tenancy is the default in every layer — Finbuckle strategies, EF Core global query filter, IGlobalEntity opt-out, named filters, cross-tenant query discipline. Source: https://fullstackhero.net/docs/architecture/multitenancy-deep-dive/ Multitenancy isn't a module in fullstackhero. It's a property of the platform — every EF Core entity is tenant-aware by default, every cache is tenant-scoped, every background job carries the tenant context that enqueued it, and every audit row records which tenant it belongs to. You opt **out** with `IGlobalEntity`, not in. This page explains how that works mechanically, the design decisions behind it, and the rules for the rare cross-tenant query. ## Three layers, one default Tenancy is enforced at three layers, each protecting the next: | Layer | Where | What enforces it | |---|---|---| | HTTP request | Multitenancy module (`UseHeroMultiTenantDatabases()` in `Program.cs`) | Finbuckle's `UseMultiTenant()` resolves the tenant from the `tenant` header or `?tenant=` query and sets the context — first thing in the pipeline. | | Data access | `BuildingBlocks/Persistence` | `BaseDbContext` applies Finbuckle's tenant query filter to every entity that doesn't opt out. | | Background work | `BuildingBlocks/Jobs` | `FshJobFilter` captures the tenant at enqueue; `FshJobActivator` restores it before the job runs. | You don't write `WHERE tenant_id = @tenantId` in your queries. The filter is on every entity, every time, unless you explicitly bypass it. ## Layer 1 — Tenant resolution Finbuckle.MultiTenant 10 resolves the tenant on every request. The kit configures the chain: ```csharp // MultitenancyModule.ConfigureServices (simplified) builder.Services.AddMultiTenant() .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim — no-op pre-auth (see below) .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header — the primary resolver .WithDelegateStrategy(/* ?tenant= query fallback */) // 3. query string .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) .WithStore>(ServiceLifetime.Scoped); ``` Finbuckle's strategy chain runs **before** `UseAuthentication()`. The claim strategy therefore sees an anonymous `User` on every normal request and is effectively a no-op. It's there for the **cross-tenant impersonation** case, where post-auth middleware re-resolves the tenant from the impersonation grant's claim. In the normal flow the header strategy is the primary resolver. The kit caches resolved tenants in `DistributedCacheStore` for 60 minutes (Valkey backplane) to keep hot-path requests off the DB. The `EFCoreStore` is the source of truth. ### The root-operator override A SuperAdmin (whose JWT carries `tenant=root`) can scope a single request to another tenant. This is needed for cross-tenant admin operations like searching users in tenant X before starting impersonation. The kit implements this as **post-auth middleware**, not as a Finbuckle strategy — because the strategy chain runs before auth, it can't tell whether the caller is actually the root user. The middleware lives in `MultitenancyModule.ConfigureMiddleware` (which `UseModuleMiddlewares` runs right after `UseAuthentication`): it checks the caller's `tenant` claim is `root`, reads the `tenant` header, looks the target up in the store, and swaps the resolved context via `IMultiTenantContextSetter`. The same `ConfigureMiddleware` also hosts the **deactivated/expired-tenant guard**: every request from a non-root tenant is rejected if the tenant is inactive or past `ValidUpto` plus the configured grace window — enforced per request, not just at login. ## Layer 2 — Data access `BaseDbContext` (in `BuildingBlocks/Persistence`) is the EF Core base every module's `DbContext` inherits. Its `OnModelCreating` does two things: ```csharp // BuildingBlocks/Persistence/Context/BaseDbContext.cs protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.AppendGlobalQueryFilter(QueryFilters.SoftDelete, s => !s.IsDeleted); base.OnModelCreating(modelBuilder); // Default-on tenant isolation: entities not marked IGlobalEntity get IsMultiTenant(). modelBuilder.ApplyTenantIsolationByDefault(); } ``` `ApplyTenantIsolationByDefault()` walks every entity that doesn't implement `IGlobalEntity` and calls `IsMultiTenant().AdjustUniqueIndexes()` on it — Finbuckle's hook to install the tenant filter (and widen unique indexes to include the tenant column). The result: a query like `await db.Products.ToListAsync(ct)` automatically becomes `WHERE tenant_id = @currentTenantId`. The filters are applied inside `BaseDbContext.OnModelCreating`. A module `DbContext` that overrides `OnModelCreating` to add its own entity configuration must call `base.OnModelCreating(modelBuilder)` **last** — EF only honors a query filter declared after the entity is mapped. Call `base` first (before your `ApplyConfigurationsFromAssembly`) and the tenant + soft-delete filters silently fail to attach, and every query leaks across tenants. This is the single most common way to accidentally break isolation in a new module. `SaveChangesAsync` sets `TenantNotSetMode = TenantNotSetMode.Overwrite` so any entity entering Added state without an explicit `TenantId` inherits the current tenant. No `entity.TenantId = current.GetTenant()` lines in your handlers. ### Two opt-outs **`IGlobalEntity`** — for entities that need to live across tenants. The kit uses it for: - `BillingPlan` — the plan catalogue is platform-wide - `ImpersonationGrant` — operator impersonation spans tenants - `OutboxMessage`, `InboxMessage` — eventing infrastructure If you mark an entity `IGlobalEntity`, the global tenant filter is **not** applied. Queries against the table return rows for every tenant; access control becomes the handler's responsibility. **`IgnoreQueryFilters([QueryFilters.SoftDelete])`** — the kit registers SoftDelete as a *named* filter (the tenant filter stays anonymous, Finbuckle-owned), so individual queries can drop just the soft-delete filter without touching the tenant filter: ```csharp var trashed = await db.Products .IgnoreQueryFilters([QueryFilters.SoftDelete]) .Where(p => p.IsDeleted) .ToListAsync(ct).ConfigureAwait(false); ``` This is the pattern for "list trashed items" queries (see `ListTrashedProductsQueryHandler` and friends). The tenant filter still applies; the soft-delete filter doesn't. ### Cross-tenant queries (rare, but real) Auditing needs to query across tenants — a root operator pulling another tenant's audit trail. The real code, from the Auditing module's `GetAuditsQueryHandler`: ```csharp // Cross-tenant access requires an explicit permission check first… var allowed = await _permissions .HasPermissionAsync(userId, AuditingPermissions.AuditTrails.ViewCrossTenant, ct) .ConfigureAwait(false); if (!allowed) { throw new ForbiddenException("Cross-tenant audit access requires Permissions.AuditTrails.ViewCrossTenant."); } // …then bypass the filter and IMMEDIATELY re-scope to the requested tenant. return _dbContext.AuditRecords .AsNoTracking() .IgnoreQueryFilters() .Where(a => a.TenantId == requested); ``` The pattern: gate with an explicit permission, use `IgnoreQueryFilters()` deliberately, then **re-filter explicitly** to the exact scope the caller asked for — never return rows for *all* tenants. Don't sprinkle this; reserve it for specific admin / audit query handlers. ## Layer 3 — Background work Hangfire jobs run on threads that have no `HttpContext`, so the tenant context resolved on the originating request is gone by the time the job dequeues. Two pieces in `BuildingBlocks/Jobs` handle this: - **At enqueue time** — `FshJobFilter` reads the current tenant (via `IMultiTenantContextAccessor`) and user id, and stores them as Hangfire job parameters. - **At execute time** — `FshJobActivator` reads the stored parameters back and pushes the tenant onto `IMultiTenantContextSetter` (and the user onto `ICurrentUserInitializer`) before the job body runs. The job sees the same tenant the request did. The same pattern shows up in integration-event handlers: `WebhookFanoutHandler` installs the event's `TenantId` via `IMultiTenantContextSetter` before querying the subscription table, then restores the previous context in a `finally`. Forget this and the Finbuckle query filter returns no rows on the background thread. ## Per-tenant connection strings Each `AppTenantInfo` row carries an optional `ConnectionString`. When set, `BaseDbContext.OnConfiguring` switches the DbContext to that connection at runtime. This lets a high-value tenant get its own database without changing application code. When not set, all tenants share the kit's main database with row-level isolation via the global query filter. For most teams, row-level isolation is sufficient through the kit's lifecycle and scales further than you'd expect — Postgres handles hundreds of thousands of tenants in one database fine. Reach for per-tenant databases only when you have a compliance requirement (HIPAA, PCI-DSS) or a single tenant whose data volume genuinely needs its own physical store. ## Caching tenant-scoped data The Caching block (`HybridCache`) is tenant-aware **by convention**: include `tenantId` in every cache key. The kit's pattern: ```csharp await cache.GetOrCreateAsync( $"product:{tenantId}:{productId}", async (ct) => await db.Products.FirstAsync(...), tags: [$"tenant:{tenantId}"], cancellationToken: ct).ConfigureAwait(false); ``` The tag-based eviction lets you invalidate a tenant's entire cache on plan change, suspension, or sign-out (`CacheKeys.Tags.Tenant(...)` is the kit's helper for the tag): ```csharp await cache.RemoveByTagAsync(CacheKeys.Tags.Tenant(tenantId), ct).ConfigureAwait(false); ``` L1 (in-memory) cache has no backplane; the kit's `DefaultLocalCacheExpiration` (2 minutes) bounds the cross-node staleness window after a peer's `RemoveByTag`. ## The documented exception: Billing One module deliberately steps outside the default. `BillingDbContext` is a plain `DbContext` — **not** `BaseDbContext` — so the auto-applied tenant filter does **not** govern it. Subscriptions, invoices, and usage snapshots instead carry an explicit `TenantId` column, and the billing query services filter on it by hand. This is intentional: billing is a finance/admin concern that routinely queries *across* tenants — the monthly invoice job iterates every active tenant, and operators pull cross-tenant invoice reports. Forcing it through the per-request tenant filter would fight the feature. The trade-off is that **billing handlers own their tenant scoping**: every query that should be tenant-bound must say so (`.Where(x => x.TenantId == current)`), because nothing does it for them. The plan catalogue (`BillingPlan`) goes further and is `IGlobalEntity` — a single platform-wide list of plans shared by all tenants. See the [Billing module](/docs/modules/billing/). Default to `BaseDbContext` (isolation on). Reach for a plain `DbContext` with a manual `TenantId` column only when the module's whole job is cross-tenant administration — and document it, like Billing does. ## Per-tenant migrations & provisioning Because each tenant can have its own schema (and optionally its own database), migrations run per tenant. The [DbMigrator](/docs/deployment/database-migrations/) applies the **tenant catalog first**, then walks **each tenant's per-module schema**, serialized by a Postgres advisory lock so two migrator instances can't collide. New tenants are provisioned asynchronously. `CreateTenantCommand` returns immediately, buffers the operator-supplied admin password in `ITenantInitialPasswordBuffer` (a singleton — there is no hard-coded default password), and queues a Hangfire job that walks ordered, resumable steps (Database → Migrations → Seeding → CacheWarm). A tenant is only **activated once provisioning reaches `Completed`**; a failed step is resumable via `RetryTenantProvisioning`. Full lifecycle in the [Multitenancy module](/docs/modules/multitenancy/). ## Testing isolation Isolation is verified, not assumed. Integration tests at `src/Tests/Integration.Tests/Tests/Multitenancy/` assert that data created under tenant A is invisible to tenant B, that the header override is gated to the root operator, and that seeding is tenant-scoped. Finbuckle's tenant context flows through `AsyncLocal`. In an integration test, set it **in the same method** as the `DbContext` / `UserManager` call — if you set it inside an awaited helper, the value is lost across the async boundary and the tenant query filter NREs (it has no tenant to filter on). This is the most common cause of a flaky tenant test. ## What tenancy is NOT - **Tenant isolation is not a security boundary against compromised code.** A bug that calls `IgnoreQueryFilters()` returns cross-tenant data. Architecture tests can catch obvious cases; code review catches the rest. Keep the bypass calls audited. - **Tenant context is not propagated to outbound HTTP calls automatically.** When you call a third-party API on behalf of a tenant, attach the tenant id explicitly to the request (header, OAuth scope, audit metadata). - **Tenant identity is not user identity.** A user can belong to multiple tenants (the operator impersonation case). Use `ICurrentUser.GetUserId()` for user identity; use `ICurrentUser.GetTenant()` for the resolved tenant of the current request. ## Related - [Multitenancy module](/docs/modules/multitenancy/) — the module that owns tenant lifecycle. - [Billing module](/docs/modules/billing/) — the documented cross-tenant exception. - [Database Migrations](/docs/deployment/database-migrations/) — per-tenant migration ordering. - [Persistence](/docs/building-blocks/persistence/) — `BaseDbContext` and the auto-applied filters. - [Jobs](/docs/building-blocks/jobs/) — `FshJobFilter` and tenant restoration. - [Webhooks module](/docs/modules/webhooks/) — the cross-thread "restore tenant context manually" pattern. --- # Vertical Slice Architecture > How each module is structured inside — one feature, one folder, one PR; endpoint + command + handler + validator + tests living together. Source: https://fullstackhero.net/docs/architecture/vertical-slice/ Inside every fullstackhero module, **Vertical Slice Architecture** (VSA) determines the shape. A feature — registering a user, changing a product price, sending a chat message — lives in one feature folder: the endpoint, the handler, and the validator together, with the command and response mirrored in the module's `.Contracts` project. No layered round-trip. No Domain → Application → Infrastructure → API hops. One feature, one folder, one PR. In Vertical Slice the answer is "one folder, maybe two files." In a layered architecture the answer is "one DTO, one application service, one domain method, one infrastructure repository, one controller method, and probably four interface declarations to wire them." VSA optimises for the change you actually make. ## The slice shape A slice is split across the module's two projects. `RegisterUser` looks like this: ``` Modules.Identity.Contracts/v1/Users/RegisterUser/ ├── RegisterUserCommand.cs ← public surface (ICommand) └── RegisterUserResponse.cs ← public surface (returned by the handler) Modules.Identity/Features/v1/Users/RegisterUser/ ├── RegisterUserEndpoint.cs ← minimal API mapping ├── RegisterUserCommandHandler.cs ← Mediator handler └── RegisterUserCommandValidator.cs ← FluentValidation ``` The command + response live in Contracts because they're the public surface anyone (including other modules) reads. The endpoint, handler, and validator live in the runtime project because they're private to the module. Tests for the slice live in the module's test project (e.g. `src/Tests/Identity.Tests/Handlers/RegisterUserCommandHandlerTests.cs`), and `src/Tests/Architecture.Tests/` enforces the structural rules — handlers paired with validators, Contracts free of EF/FluentValidation, versioned features not reaching into newer versions. ## A complete slice The cleanest way to learn VSA is to read one slice end-to-end. Here's `RegisterUser`, condensed: ```csharp // Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserCommand.cs public class RegisterUserCommand : ICommand { public string FirstName { get; set; } = default!; public string LastName { get; set; } = default!; public string Email { get; set; } = default!; public string UserName { get; set; } = default!; public string Password { get; set; } = default!; public string ConfirmPassword { get; set; } = default!; public string? PhoneNumber { get; set; } [JsonIgnore] public string? Origin { get; set; } // set by the endpoint, not the client } // Modules.Identity.Contracts/v1/Users/RegisterUser/RegisterUserResponse.cs public record RegisterUserResponse(string UserId); ``` ```csharp // Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs public sealed class RegisterUserCommandValidator : AbstractValidator { public RegisterUserCommandValidator() { RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100); RuleFor(x => x.LastName).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).NotEmpty().EmailAddress(); RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(50); RuleFor(x => x.Password).NotEmpty().MinimumLength(6); RuleFor(x => x.ConfirmPassword).NotEmpty() .Equal(x => x.Password).WithMessage("Passwords do not match."); } } ``` ```csharp // Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs public sealed class RegisterUserCommandHandler : ICommandHandler { private readonly IUserService _userService; public RegisterUserCommandHandler(IUserService userService) => _userService = userService; public async ValueTask Handle(RegisterUserCommand command, CancellationToken cancellationToken) { string userId = await _userService.RegisterAsync( command.FirstName, command.LastName, command.Email, command.UserName, command.Password, command.ConfirmPassword, command.PhoneNumber ?? string.Empty, command.Origin ?? string.Empty, cancellationToken).ConfigureAwait(false); return new RegisterUserResponse(userId); } } ``` ```csharp // Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserEndpoint.cs public static class RegisterUserEndpoint { internal static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/register", async (RegisterUserCommand command, HttpContext context, IMediator mediator, CancellationToken cancellationToken) => { command.Origin = $"{context.Request.Scheme}://{context.Request.Host.Value}{context.Request.PathBase.Value}"; var result = await mediator.Send(command, cancellationToken); return TypedResults.Created($"/api/v1/identity/users/{result.UserId}", result); }) .WithName("RegisterUser") .WithSummary("Register user") .RequirePermission(IdentityPermissions.Users.Create) .WithIdempotency(); // Idempotency-Key header support } } ``` That's the entire feature. Five small files. One slice. One PR. ## Conventions The kit enforces a small set of conventions through code review and architecture tests: - **Handlers are `public sealed`.** Mediator's source generator requires concrete types; `sealed` is the default modifier so nothing inherits handlers. - **Handlers return `ValueTask`.** Allocation-friendly for the synchronous fast path. - **Every `await` uses `.ConfigureAwait(false)`.** This is library code; never capture the synchronization context. - **Commands and queries are `record`s or `sealed` classes.** Enforced by an architecture test (`Commands_And_Queries_Should_Be_Records_Or_Sealed`). Prefer records; reach for a class when the endpoint needs to enrich the command (like `RegisterUserCommand.Origin` above). - **Endpoints are static extension methods on `IEndpointRouteBuilder`.** No controllers. No `[ApiController]`. Just minimal APIs. - **One endpoint per slice.** A slice represents one feature; one feature has one HTTP entrypoint. `ChangeProductPrice` has `PATCH /products/{productId:guid}/price`; `UpdateProduct` has `PUT /products/{productId:guid}` — separate slices because they're separate operations with separate events. - **Validators live next to handlers.** The module loader auto-registers FluentValidation validators from each module's assembly. - **Permissions live in `Contracts/Authorization/`.** Endpoints reference them via `.RequirePermission(...)`. Permission constants are registered once during module startup. ## Why not layered Clean Architecture? Clean Architecture (Jason Taylor, Ardalis) is a valid alternative — many teams use it well. The kit picks VSA because most production changes are **cohesive feature changes**, not cross-cutting refactors. When a stakeholder asks "can we add a discount code to product pricing?", you want one folder to open, not five. The trade-off: cross-cutting refactors (rename `Product.Sku` to `Product.SkuCode` everywhere) are slightly more tedious in VSA because the affected slices are scattered. Modern IDEs handle this fine; if your team makes a lot of cross-cutting changes, layered architecture is a better fit. If your team ships features more than it refactors abstractions, VSA wins. The kit's three architectural pillars — modular monolith, vertical slice inside each module, integration events across modules — combine well. Each module is a clean unit of ownership; each slice is a clean unit of change. ## CQRS without ceremony VSA goes well with CQRS — separating commands (writes that emit events) from queries (reads that project data). The kit uses Mediator 3 (source-generated, no runtime reflection) so the distinction is just two marker interfaces: ```csharp public sealed record CreateProductCommand(...) : ICommand; public sealed record GetProductByIdQuery(Guid Id) : IQuery; ``` Handlers implement `ICommandHandler` or `IQueryHandler`. Mediator's source generator wires the dispatch table at compile time. There's no `IRequestHandler<,>` magic, no runtime reflection, no startup scan. For most slices, command and query handlers look almost the same — they take a DTO, do the work, return a DTO. The point isn't ceremony, it's intent: a `Query` handler is allowed to skip `SaveChanges`; a `Command` handler is allowed to `RaiseDomainEvent`. Keeping the convention makes the read / write distinction obvious to future readers. ## What VSA doesn't do - **It doesn't replace your domain model.** Aggregates still live in the `Domain/` folder; the slice's handler invokes domain methods. VSA is about how features are organized; it has no opinion on how aggregates enforce invariants. - **It doesn't enforce database isolation.** Each module's `DbContext` is its own slice of the DB, but inside a module multiple slices share the same `DbContext`. Use the Specification pattern (from the Persistence block) to keep queries reusable. - **It doesn't make architecture tests optional.** Slices spread the logic; arch tests make sure the spread doesn't drift into chaos. Read `src/Tests/Architecture.Tests/` to see what's enforced. ## Related - [Modular monolith](/docs/architecture/modular-monolith/) — the outer structure that contains the slices. - [Modules overview](/docs/modules/) — every module is structured this way. - [Catalog module](/docs/modules/catalog/) — the most slice-dense module; read its `Features/v1/` for examples. - [Dependency injection](/docs/architecture/dependency-injection/) — how the module loader wires slice handlers. --- # Overview > Shared framework libraries — Core, Persistence, Web, Caching, Eventing, Jobs, Mailing, Storage, Quota. Source: https://fullstackhero.net/docs/building-blocks/ The shared libraries every module composes against. Stable, opinionated, and meant to be read. --- # Caching building block > HybridCache (L1 in-memory + L2 Valkey or distributed memory) with OpenTelemetry instrumentation, stampede protection, and a shared multiplexer for DataProtection. Source: https://fullstackhero.net/docs/building-blocks/caching/ The Caching block wires `Microsoft.Extensions.Caching.Hybrid` for the whole platform. L1 is in-process memory; L2 is **Valkey** (a Redis-compatible, BSD-licensed Redis fork) when `CachingOptions:Redis` is set, otherwise the in-process `DistributedMemoryCache`. The block decorates the underlying `HybridCache` with `ObservableHybridCache` to emit OpenTelemetry metrics + activities, and it shares the `IConnectionMultiplexer` with DataProtection key persistence so the whole host opens **one Valkey connection pool**, not one per feature. HybridCache coalesces concurrent identical requests into a single source fetch automatically. The first caller blocks on the source; the rest of the wave wait on the same task. You don't write lock code; you just call `GetOrCreateAsync` and the cache does the right thing. ## What it ships ### Extension - **`AddHeroCaching(services, configuration)`** — wires HybridCache + L2 + the OTel decorator. Reads `CachingOptions`. ### Decorator - **`ObservableHybridCache(inner)`** — wraps the framework `HybridCache` to emit: - `fsh.cache.hits` / `fsh.cache.misses` (OTel counters; L1 and L2 hits are both counted as "hit" — HybridCache doesn't surface which layer served the read) - `fsh.cache.invalidations` (OTel counter; `RemoveAsync` + `RemoveByTagAsync`) - `fsh.cache.factory.duration` (OTel histogram, ms; recorded only on a miss) - Activities under `ActivitySource("FSH.Caching")` with `cache.system`, `cache.key`, `cache.hit` (or `cache.tag`) tags. ### Options - **`CachingOptions`**: - `Redis` — connection string; empty falls back to in-memory `DistributedMemoryCache` - `EnableSsl` — TLS toggle (defaults to inference from the connection string) - `DefaultExpiration` — TimeSpan; default 1 hour, applies to L1 + L2 - `DefaultLocalCacheExpiration` — TimeSpan; default 2 minutes; bounds cross-node staleness after RemoveByTag on peers - `MaximumKeyLength` — default 1024 chars; longer keys are rejected - `MaximumPayloadBytes` — default 1 MB; larger payloads are silently skipped ### Telemetry helper - **`CachingTelemetry`** — the `ActivitySource` + meter the decorator emits (both named `FSH.Caching`, exposed as `ActivitySourceName` / `MeterName` constants). Wire them into the OTel pipeline via `metrics.AddMeter(...)` / `tracing.AddSource(...)`. ## How modules consume it Inject `HybridCache` and call `GetOrCreateAsync`: ```csharp public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo) : IQueryHandler { public ValueTask Handle(GetTenantConfigQuery q, CancellationToken ct) => cache.GetOrCreateAsync( $"tenant-config:{q.TenantId}", async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false), cancellationToken: ct); } ``` For tag-based invalidation: ```csharp await cache.GetOrCreateAsync(key, factory, tags: ["tenant:" + tenantId.ToString()], cancellationToken: ct); // later, on tenant config change await cache.RemoveByTagAsync("tenant:" + tenantId.ToString(), ct).ConfigureAwait(false); ``` Identity uses HybridCache for per-user permission sets (`UserPermissionService`, warmed by the `RolePermissionSyncer`) and impersonation grants (`ImpersonationGrantService`); Multitenancy caches tenant themes (`TenantThemeService`) and fronts the tenant EF store with Finbuckle's `DistributedCacheStore`, which rides on the same `IDistributedCache` this block registers. ## Configuration ```jsonc { "CachingOptions": { "Redis": "localhost:6379", "DefaultExpiration": "01:00:00", "DefaultLocalCacheExpiration": "00:02:00", "MaximumKeyLength": 1024, "MaximumPayloadBytes": 1048576 } } ``` When `Redis` is empty the block falls back to in-memory L2 (`DistributedMemoryCache`) — fine for dev/test, **not** for multi-instance production. Always set Valkey (or another distributed cache) for production. ## How to extend ### Use the shared multiplexer for your own Valkey work The block exposes the `IConnectionMultiplexer` through DI; resolve it and reuse instead of opening a second pool: ```csharp public sealed class MyRedisService(IConnectionMultiplexer redis) { public async Task ReadAsync(string key) { var db = redis.GetDatabase(); return await db.StringGetAsync(key); } } ``` ### Add another decorator Decorate the registered `HybridCache` with another wrapper to add e.g. circuit-breaker logic. Pull the existing descriptor out of the service collection, register your decorator, and chain — `ObservableHybridCache` does this exact dance for OTel. ### Pre-warm on startup Resolve `HybridCache` from a hosted service and `GetOrCreateAsync` your hot keys before traffic arrives. The Identity module's `RolePermissionSyncHostedService` is the template. ## Gotchas - **L1 has no backplane.** After `RemoveByTag` on one instance, peer instances' L1 copies aren't invalidated. The `DefaultLocalCacheExpiration` (2 min default) bounds the staleness window — don't set it too high if you rely on fast invalidation. - **Payload size cap is silent.** Entries larger than `MaximumPayloadBytes` are skipped (with a warning log), not stored. If your hot-path cache value is suddenly 1.5 MB, lookup will miss every time. Log-search for "payload too large" warnings before assuming the cache is broken. - **One multiplexer, two consumers.** Caching and DataProtection key persistence share the same `IConnectionMultiplexer`. The SignalR backplane (`AddHeroRealtime`) reads the same `CachingOptions:Redis` string but opens its **own** connection — so a multi-instance host with realtime enabled holds two Valkey connections, not one. - **`DistributedMemoryCache` is per-process.** Two instances of the API can't see each other's cache. This is intentional for test fixtures; if you accidentally ship it to multi-instance prod, multi-tenant cache invalidation breaks. Always check `CachingOptions:Redis` is set in production config. ## Critical files - `src/BuildingBlocks/Caching/Extensions.cs` - `src/BuildingBlocks/Caching/ObservableHybridCache.cs` - `src/BuildingBlocks/Caching/CachingOptions.cs` - `src/BuildingBlocks/Caching/Telemetry/CachingTelemetry.cs` ## Related - [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableCaching` toggle. - [Multitenancy module](/docs/modules/multitenancy/) — uses HybridCache as the `DistributedCacheStore`. - [Chat module](/docs/modules/chat/) — typing-indicator throttle uses the cache. --- # Core building block > The foundational types every module composes against — BaseEntity, AggregateRoot, DomainEvent, ICurrentUser, and the CustomException hierarchy. Source: https://fullstackhero.net/docs/building-blocks/core/ `FSH.Framework.Core` (package id) is the foundation every other module and building block depends on. It ships nothing runtime-active — no DI registrations, no middleware, no hosted services — just the **shared abstractions** every domain layer needs: entity bases, aggregate-root markers, the `DomainEvent` record, current-user / tenant interfaces, and the framework's exception hierarchy. Around **a few hundred lines** of interface + abstract-class definitions. If you're writing a new module, the first file you'll touch is the domain. Every aggregate inherits from one of the bases here. Get familiar with the contracts and the rest of the kit reads naturally. ## What it ships ### Entity bases - **`BaseEntity`** — abstract; carries `Id` (protected setter), a private domain-event list surfaced as `DomainEvents`, a protected `AddDomainEvent(IDomainEvent)`, and `ClearDomainEvents()`. Every persisted entity inherits from this. - **`AggregateRoot`** — extends `BaseEntity`; a semantic marker for aggregate roots. It adds no members today — it exists so the type system says "this is a consistency boundary" and gives you a place for aggregate-wide helpers later. - **`IEntity`** — the minimal entity contract: a single `TId Id { get; }`. ### Cross-cutting interfaces - **`IHasTenant`** — `string TenantId { get; }`. A read-side convenience for entities that expose their tenant id as a real property. Note: tenant isolation itself does **not** key off this interface — `BaseDbContext` marks every entity `IsMultiTenant()` via Finbuckle unless it implements `IGlobalEntity` (see [Persistence](/docs/building-blocks/persistence/)). - **`IAuditableEntity`** — `CreatedOnUtc`, `CreatedBy`, `LastModifiedOnUtc`, `LastModifiedBy` (all `DateTimeOffset`-based). `AuditableEntitySaveChangesInterceptor` fills them in automatically. - **`ISoftDeletable`** — `IsDeleted`, `DeletedOnUtc`, `DeletedBy`. The named query filter `QueryFilters.SoftDelete` hides deleted rows by default; opt out surgically with `IgnoreQueryFilters([QueryFilters.SoftDelete])` (tenant scoping stays in force). - **`IHasDomainEvents`** — implemented by `BaseEntity`; surfaces the domain-event list so the `DomainEventsInterceptor` can dispatch them after SaveChanges. - **`IGlobalEntity`** — opt-out of tenant isolation for platform-wide rows (billing plans, impersonation grants, outbox/inbox messages). ### Domain events `DomainEvent` is an abstract record: ```csharp // src/BuildingBlocks/Core/Domain/DomainEvent.cs public abstract record DomainEvent( Guid EventId, DateTimeOffset OccurredOnUtc, string? CorrelationId = null, string? TenantId = null) : IDomainEvent { public static T Create(Func factory) where T : DomainEvent => factory(Guid.NewGuid(), DateTimeOffset.UtcNow); } ``` Concrete events inherit it (this one is real, from Catalog): ```csharp public sealed record ProductPriceChangedDomainEvent( Guid ProductId, decimal OldPrice, decimal NewPrice, string Currency, Guid EventId, DateTimeOffset OccurredOnUtc) : DomainEvent(EventId, OccurredOnUtc); ``` …and aggregates raise them inside their methods. The `DomainEventsInterceptor` dispatches via Mediator after `SaveChangesAsync` completes. ### Current-user + request context - **`ICurrentUser`** — the request-time view of the authenticated user: `Name`, `GetUserId()`, `GetUserEmail()`, `GetTenant()`, `IsAuthenticated()`, `IsInRole(role)`, `GetUserClaims()`. - **`ICurrentUserInitializer`** — implemented by the Identity module's `CurrentUserService`; populates `ICurrentUser` from the `ClaimsPrincipal` (`SetCurrentUser`) or a raw id (`SetCurrentUserId`, used by Hangfire's job activator). The kit's `CurrentUserMiddleware` calls `SetCurrentUser` right after authentication, before the endpoint runs. - **`IRequestContext`** — request metadata without an ASP.NET Core dependency: `IpAddress`, `UserAgent`, `ClientId` (from the `X-Client-Id` header), `Origin`. ### Exception hierarchy The kit's global exception handler (in the Web block) turns these into `ProblemDetails` (RFC 9457): | Type | Status | Use when | |---|---|---| | `CustomException(message, errors, statusCode)` | configurable (default 500) | Generic domain failure; pick the status | | `NotFoundException` | 404 | Resource missing | | `ForbiddenException` | 403 | Authenticated but not authorised | | `UnauthorizedException` | 401 | Not authenticated | All four are plain exception classes. `CustomException` carries an `ErrorMessages` list (validation errors, business-rule details) and a `StatusCode`, and has overloads that accept an inner exception when you're wrapping a lower-level failure. Domain code throws them; the handler does the HTTP translation. ## How modules consume Core Every aggregate inherits one of the bases. Every persisted entity opts in or out of the cross-cutting interfaces: ```csharp // Example from Catalog (no TenantId property needed — Finbuckle adds the // tenant column and filter automatically via BaseDbContext) public sealed class Product : AggregateRoot, ISoftDeletable { public bool IsDeleted { get; private set; } public DateTimeOffset? DeletedOnUtc { get; private set; } public string? DeletedBy { get; private set; } public void ChangePrice(Money newPrice) { decimal oldAmount = Price.Amount; Price = newPrice; AddDomainEvent(DomainEvent.Create((id, ts) => new ProductPriceChangedDomainEvent(Id, oldAmount, newPrice.Amount, newPrice.Currency, id, ts))); } } ``` `ICurrentUser` is injected by constructor anywhere you need to attribute work to a user: ```csharp public sealed class StartImpersonationCommandHandler(IIdentityDbContext db, ICurrentUser current) : ICommandHandler { public async ValueTask Handle(StartImpersonationCommand cmd, CancellationToken ct) { var actor = current.GetUserId(); // never null inside an authenticated endpoint // ... } } ``` ## How to extend ### Add a new cross-cutting interface If you want every entity to track, say, `LastViewedAt`, add `IHasLastViewed` to Core, add the auto-update logic to a new EF Core interceptor in Persistence, and implement the interface on the entities that care. The kit's pattern is: cross-cutting capability = interface + interceptor. ### Add a new exception type Inherit `CustomException`, pass the right status code in the base constructor. Don't bypass the handler — keep the handler's RFC 9457 mapping as the single source of truth for HTTP error shape. ## Gotchas - **`AddDomainEvent` is `protected`** on `BaseEntity`. Domain events must be raised from inside the aggregate, not from a handler. That's by design: it keeps invariants and event emission co-located. - **`DomainEvent.Create(factory)`** is the static helper that fills in `EventId` and `OccurredOnUtc` automatically. Use it; don't construct events manually with `Guid.NewGuid()` scattered through code. - **Inner exceptions never reach the response.** `CustomException` accepts an inner exception for logging/diagnostics, but the global handler only maps the message, `ErrorMessages`, and status code into ProblemDetails — keep the public message clean and let logs carry the cause. ## Critical files - `src/BuildingBlocks/Core/Domain/BaseEntity.cs` - `src/BuildingBlocks/Core/Domain/AggregateRoot.cs` - `src/BuildingBlocks/Core/Domain/DomainEvent.cs` - `src/BuildingBlocks/Core/Context/ICurrentUser.cs` - `src/BuildingBlocks/Core/Exceptions/CustomException.cs` ## Related - [Persistence](/docs/building-blocks/persistence/) — `BaseDbContext` and the interceptors that consume these interfaces. - [Web](/docs/building-blocks/web/) — `CurrentUserMiddleware` populates `ICurrentUser`. - [Architecture overview](/docs/architecture/) — modular monolith + Vertical Slice patterns. --- # Eventing building block > Event bus implementation (InMemory or RabbitMQ), outbox/inbox stores for durable delivery, and the open-generic handler registration that lets modules subscribe with one method. Source: https://fullstackhero.net/docs/building-blocks/eventing/ The Eventing block is the runtime side of the kit's integration-event story. It ships two `IEventBus` implementations — `InMemoryEventBus` (synchronous, in-process) and `RabbitMqEventBus` (durable, cross-service) — an EF Core-backed outbox store for transactional delivery, an inbox store for receiver-side idempotency, a `JsonEventSerializer`, and the auto-discovery hook that registers every `IIntegrationEventHandler` in your module assemblies. Publish goes through the outbox in the same transaction as your business write. A background dispatcher hands it to RabbitMQ. The receiver's inbox dedupes by event id, so if the broker redelivers (network blip, consumer crash) the handler doesn't fire twice. Two stores, one obvious pattern. ## What it ships ### Extensions - **`AddEventingCore(services, configuration)`** — registers `JsonEventSerializer`, a no-op `IEventTenantScope` default (the Multitenancy module swaps in a Finbuckle-backed one), and picks the `IEventBus` from `EventingOptions:Provider` (`InMemory` default, `RabbitMQ`). Adds the outbox dispatcher hosted service when `UseHostedServiceDispatcher` is true. - **`AddEventingForDbContext(services)`** — registers `EfCoreOutboxStore` + `EfCoreInboxStore` (scoped) plus the `OutboxDispatcher` scoped service. - **`AddIntegrationEventHandlers(services, assemblies[])`** — scans the supplied assemblies for `IIntegrationEventHandler` implementations and registers them scoped in DI. ### Event bus implementations - **`InMemoryEventBus`** — in-process. For each event it **sets the tenant context first** (`IEventTenantScope.Begin(event.TenantId)`), creates a fresh DI scope, resolves the matching handlers, checks the inbox (skip if already processed by that handler), awaits each handler in order, then marks the inbox row. Great for dev/test and single-process production hosts. - **`RabbitMqEventBus`** — durable, cross-service; uses `RabbitMQ.Client` and publishes to a durable topic exchange (`EventingOptions:RabbitMQ`). ### Outbox / inbox - **`IOutboxStore`** (`AddAsync`, `GetPendingBatchAsync`, `MarkAsProcessedAsync`, `MarkAsFailedAsync`) + `EfCoreOutboxStore` — serialize integration events into an `OutboxMessages` table in your module's DbContext. `AddAsync` rides the same DbContext as your business write, so inside an open transaction the event commits atomically with it. - **`IInboxStore`** + `EfCoreInboxStore` — dedupe table keyed by **(event id, handler name)**, so each handler processes an event at most once. - **`OutboxDispatcher`** — scoped service; reads pending rows in batches (`OutboxBatchSize`, default 100), deserializes, publishes via `IEventBus`, marks processed. A failing row increments `RetryCount`; after `OutboxMaxRetries` (default 5) it's flagged `IsDead` and skipped thereafter. - **`OutboxDispatcherHostedService`** — background loop calling the dispatcher every `OutboxDispatchIntervalSeconds` (default 10). - **`OutboxMessage`** — `Id`, `CreatedOnUtc`, `Type`, `Payload`, `TenantId`, `CorrelationId`, `ProcessedOnUtc`, `RetryCount`, `LastError`, `IsDead`. Implements `IGlobalEntity` (background processors must scan across tenants). - **`InboxMessage`** — `Id` + `HandlerName` (composite key), `EventType`, `ProcessedOnUtc`, `TenantId`. Also `IGlobalEntity`. ### Serializer - **`JsonEventSerializer`** — System.Text.Json; the outbox stores the event's type name alongside the payload so the dispatcher can rehydrate the concrete event for publishing. ## How modules consume Eventing Register against your DbContext during module startup: ```csharp public void ConfigureServices(IHostApplicationBuilder builder) { builder.Services.AddHeroDbContext(); builder.Services.AddEventingForDbContext(); } ``` There are two publish paths, and the kit uses both: **Durable (outbox)** — write the event to `IOutboxStore` next to your business change; the dispatcher publishes it later. This is what Identity does for `UserRegisteredIntegrationEvent`: ```csharp public async ValueTask Handle(ResolveTicketCommand cmd, CancellationToken ct) { var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false); ticket!.Resolve(cmd.ResolutionNote); await _db.SaveChangesAsync(ct).ConfigureAwait(false); await _outbox.AddAsync(new TicketResolvedIntegrationEvent(/* … */), ct).ConfigureAwait(false); return Unit.Value; } ``` **Immediate** — call `IEventBus.PublishAsync` directly and handlers run right away (in-process with the InMemory bus). Chat's mention events and the tenant-lifecycle events go this way; you trade durability for latency. The `OutboxDispatcher` picks up pending rows on the next interval (or you can call `DispatchAsync` from a Hangfire job if you don't want a hosted-service loop). The receiving side declares a handler: ```csharp public sealed class TicketResolvedNotifyHandler(/* … */) : IIntegrationEventHandler { public async Task HandleAsync(TicketResolvedIntegrationEvent evt, CancellationToken ct = default) { // write a notification, send an email, etc. } } ``` Host registers handlers in bulk via `AddIntegrationEventHandlers` against all module marker assemblies: ```csharp builder.Services.AddIntegrationEventHandlers(moduleAssemblies); ``` ## Configuration ```jsonc { "EventingOptions": { "Provider": "InMemory", // or "RabbitMQ" "OutboxBatchSize": 100, "OutboxMaxRetries": 5, "EnableInbox": true, "OutboxDispatchIntervalSeconds": 10, "UseHostedServiceDispatcher": true, "RabbitMQ": { "Host": "rabbitmq", "Port": 5672, "UserName": "guest", "Password": "guest", "VirtualHost": "/", "ExchangeName": "fsh.events", "QueuePrefix": "fsh", "UseSsl": false, "PublishRetryCount": 3, "PublishRetryDelayMs": 1000 } } } ``` Set `UseHostedServiceDispatcher = false` when you'd rather drive the dispatcher from Hangfire on a fixed schedule (more deterministic for some ops setups). ## How to extend ### Add another transport Implement `IEventBus`; register your implementation in place of `InMemoryEventBus` / `RabbitMqEventBus`. Bus consumers don't care which one is wired. ### Add a side-channel like Outbox-to-Kafka `OutboxDispatcher` is small and replaceable. Subclass or wrap it to publish to Kafka in addition to RabbitMQ, or to write to multiple destinations. ### Skip the outbox for cheap fire-and-forget The outbox is opt-in: `IEventBus.PublishAsync` already goes straight to the bus. If an event doesn't need transactional durability (a cache invalidation hint, a metrics ping), just publish it directly and skip `IOutboxStore` — that's exactly what Chat and the tenant-lifecycle events do. ## Gotchas - **Domain events vs integration events** are different things in the kit. Domain events fire **inside the SaveChanges interceptor** (synchronous, in-module, via Mediator). Integration events go through the outbox and `IEventBus` (asynchronous, cross-module / cross-service). Use the right one — domain events for invariants and bookkeeping inside the module, integration events for everything else. - **Outbox dispatcher is scoped per cycle.** Each poll gets a fresh DbContext scope; a failing publish increments the row's `RetryCount` (with `LastError` recorded) and is retried on the next interval. At `OutboxMaxRetries` the row is flagged `IsDead` and skipped from then on — monitor/clean dead rows, they don't retry themselves. - **Inbox dedupes per handler, by `(EventId, HandlerName)`.** A redelivered event is skipped only for handlers that already completed it; if you mint two events with the same `Id`, the second is silently dropped for every handler. Always generate a fresh `Guid` per event. - **Background publishers need the tenant context set.** `InMemoryEventBus` does this for you via `IEventTenantScope` (reading `event.TenantId`) *before* resolving handlers — a `MultiTenantDbContext` captures its tenant at construction, so setting it later is too late. If you build your own bus or dispatch path, preserve this ordering or tenant-filtered handlers NRE. - **RabbitMQ publishing retries in-process** (`PublishRetryCount` / `PublishRetryDelayMs`), but consumers own their durability via inbox + retry semantics. ## Critical files - `src/BuildingBlocks/Eventing/ServiceCollectionExtensions.cs` - `src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs` - `src/BuildingBlocks/Eventing/RabbitMq/RabbitMqEventBus.cs` - `src/BuildingBlocks/Eventing/Outbox/OutboxDispatcher.cs` - `src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs` ## Related - [Eventing.Abstractions](/docs/building-blocks/eventing-abstractions/) — the dependency-free contracts. - [Notifications module](/docs/modules/notifications/) — consumes integration events into inbox rows. - [Webhooks module](/docs/modules/webhooks/) — uses an open-generic handler to fan all events to HTTP subscribers. --- # Eventing.Abstractions building block > Dependency-free contracts for cross-module and cross-service events — IIntegrationEvent, IIntegrationEventHandler, IEventBus, IEventSerializer. Source: https://fullstackhero.net/docs/building-blocks/eventing-abstractions/ The Eventing.Abstractions block exists for one reason: **contracts assemblies should not pull in EF Core, RabbitMQ, or the Eventing runtime when they declare an event.** This block has zero NuGet dependencies — just five interfaces. It's the smallest building block in the kit. A module's `*.Contracts` assembly references **only** `Eventing.Abstractions`. The module's runtime assembly references both this block and the heavier `Eventing` block (which carries the EF Core / RabbitMQ implementations). That keeps cross-module dependencies one-way, contract-only, and lightweight. ## What it ships Five interfaces. That's it. ```csharp // IIntegrationEvent.cs public interface IIntegrationEvent { Guid Id { get; } DateTime OccurredOnUtc { get; } string? TenantId { get; } // null for global, non-tenant-scoped events string CorrelationId { get; } // ties events to requests and traces string Source { get; } // e.g. "Modules.Identity" } // IIntegrationEventHandler.cs public interface IIntegrationEventHandler where TEvent : IIntegrationEvent { Task HandleAsync(TEvent @event, CancellationToken ct = default); } // IEventBus.cs public interface IEventBus { Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default); Task PublishAsync(IEnumerable events, CancellationToken ct = default); } // IEventSerializer.cs public interface IEventSerializer { string Serialize(IIntegrationEvent @event); IIntegrationEvent? Deserialize(string payload, string eventTypeName); } // IEventTenantScope.cs — ambient tenant context for event dispatch public interface IEventTenantScope { IDisposable Begin(string? tenantId); } ``` `IEventTenantScope` is the piece that makes background publishing safe in a multi-tenant world: the bus calls `Begin(event.TenantId)` **before** resolving handlers, because a `MultiTenantDbContext` captures its tenant at construction time. The default implementation is a no-op; the Multitenancy module registers a Finbuckle-backed one. ## Pattern: where each interface lives ``` src/Modules/Notifications/ ├── Modules.Notifications.Contracts/ │ └── (no references except Eventing.Abstractions + Core) └── Modules.Notifications/ └── (references Eventing — gets InMemoryEventBus, OutboxDispatcher, etc.) src/Modules/Chat/ ├── Modules.Chat.Contracts/ │ ├── IntegrationEvents/MentionedInChannelIntegrationEvent.cs │ │ └── implements IIntegrationEvent (Eventing.Abstractions) │ └── (no reference to Modules.Notifications.Contracts) └── Modules.Chat/ └── publishes via IEventBus (Eventing.Abstractions) ``` When Chat publishes a `MentionedInChannelIntegrationEvent`, Notifications consumes it through `IIntegrationEventHandler`. Neither runtime references the other; both just reference the event type declared in `Modules.Chat.Contracts`. The Eventing runtime (and your event bus of choice) wires the publish-to-subscribe path at runtime. ## How modules consume the contracts Define a custom event in your contracts assembly — this is Chat's real mention event: ```csharp // src/Modules/Chat/Modules.Chat.Contracts/Events/MentionedInChannelIntegrationEvent.cs public sealed record MentionedInChannelIntegrationEvent( Guid Id, DateTime OccurredOnUtc, string? TenantId, string CorrelationId, string Source, Guid ChannelId, string? ChannelName, Guid MessageId, string AuthorUserId, string MentionedUserId, string BodyPreview) : IIntegrationEvent; ``` Publish from a handler in the runtime module: ```csharp await eventBus.PublishAsync(new MentionedInChannelIntegrationEvent( Id: Guid.NewGuid(), OccurredOnUtc: DateTime.UtcNow, TenantId: currentUser.GetTenant(), CorrelationId: correlationId, Source: "Modules.Chat", /* …payload fields… */), ct).ConfigureAwait(false); ``` Consume from another module's runtime project: ```csharp public sealed class MentionedInChannelNotifyHandler(/* deps */) : IIntegrationEventHandler { public Task HandleAsync(MentionedInChannelIntegrationEvent evt, CancellationToken ct = default) { // ... } } ``` ## Gotchas - **No base class.** `IIntegrationEvent` is a pure interface. Use sealed records to implement it; do not invent a base class in this block — it would force every contracts assembly to pull in that base type's transitive deps. - **`Source` is a free-form string.** Use the canonical module name (`Modules.Tickets`, `Modules.Chat`) so logs and diagnostics stay greppable. - **`TenantId` is a nullable string.** Global, non-tenant-scoped events carry a null `TenantId`; consumers that key on tenant must guard (the Webhooks fanout handler skips null-tenant events entirely). The tenant id also drives `IEventTenantScope` — a null id leaves the ambient tenant context unchanged. - **`CorrelationId` is a non-nullable string.** It exists to tie events back to the request/trace that caused them — propagate the incoming correlation id, don't invent a new one mid-chain. - **Handler registration is open-generic friendly.** The Webhooks module registers `WebhookFanoutHandler` against the open `IIntegrationEventHandler<>`, so one handler type covers every event. If you do this, be deliberate about the side effects. ## Critical files - `src/BuildingBlocks/Eventing.Abstractions/IIntegrationEvent.cs` - `src/BuildingBlocks/Eventing.Abstractions/IIntegrationEventHandler.cs` - `src/BuildingBlocks/Eventing.Abstractions/IEventBus.cs` - `src/BuildingBlocks/Eventing.Abstractions/IEventSerializer.cs` - `src/BuildingBlocks/Eventing.Abstractions/IEventTenantScope.cs` ## Related - [Eventing](/docs/building-blocks/eventing/) — the implementation block with `InMemoryEventBus`, `RabbitMqEventBus`, outbox/inbox stores. - [Webhooks module](/docs/modules/webhooks/) — uses the open-generic `IIntegrationEventHandler<>` pattern to fan every event to subscribers. - [Notifications module](/docs/modules/notifications/) — consumes integration events into per-user inbox rows. --- # Jobs building block > Hangfire 1.8 wiring with multi-tenant context preservation, DI-aware activator, basic-auth dashboard, telemetry filter, and stale-lock cleanup. Source: https://fullstackhero.net/docs/building-blocks/jobs/ The Jobs block wires Hangfire 1.8 into the kit. The interesting parts are the **tenant-aware job filter** (preserves multi-tenant context across enqueue → execute), the **DI-aware activator** (jobs get scoped service resolution), the **OpenTelemetry filter** (activities for every job), the **basic-auth dashboard** at `/jobs`, and a **stale-lock cleanup service** for after-crash recovery. `FshJobFilter` captures the resolved tenant and the authenticated user id as job parameters at enqueue time; `FshJobActivator` restores both into the job's fresh DI scope before the body runs. You enqueue `jobs.Enqueue(j => j.RunAsync(...))` from a request and the job sees the same tenant context the request had. ## What it ships ### Extensions - **`AddHeroJobs(services)`** — registers the Hangfire server with 5 workers, 30-second heartbeat, queues `default` + `email`, and 30-second polling. Reads `DatabaseOptions:Provider` to select Postgres (`Hangfire.PostgreSql`) or SQL Server storage. Registers `FshJobActivator`, `FshJobFilter`, `LogJobFilter`, `HangfireTelemetryFilter`, plus `HangfireStaleLockCleanupService`. - **`UseHeroJobDashboard(app, configuration)`** — wires the Hangfire dashboard at the route configured in `HangfireOptions:Route` (default `/jobs`) behind a basic-auth filter using `HangfireOptions:UserName` + `HangfireOptions:Password`. ### Service interface - **`IJobService`** — convenience facade over Hangfire's static `BackgroundJob` API: `Enqueue` / `Enqueue` (with an optional queue overload), `Schedule` / `Schedule` (by `TimeSpan` delay or `DateTimeOffset`), `Delete`, `Requeue`. All return/accept Hangfire job-id strings. Recurring jobs go through Hangfire's `IRecurringJobManager` directly. - **`HangfireService : IJobService`** — implementation. ### Filters - **`FshJobActivator : JobActivator`** — resolves job classes from a fresh `IServiceScope` per job, restoring the captured tenant (via Finbuckle's `IMultiTenantContextSetter`) and user id (via `ICurrentUserInitializer`) before resolution — so the job's scoped DbContext is built with the right tenant. - **`FshJobFilter : IClientFilter`** — at enqueue time, stamps the current tenant + user id onto the job as parameters (skipped when there's no HTTP context, e.g. recurring-job creation). - **`LogJobFilter`** — logs every state transition (Enqueued, Processing, Succeeded, Failed) through `ILogger`. - **`HangfireTelemetryFilter`** — opens an OpenTelemetry activity per job with `job.name`, `job.queue`, `job.id` tags. - **`HangfireCustomBasicAuthenticationFilter`** — gates the dashboard via basic auth. ### Hosted service - **`HangfireStaleLockCleanupService`** — runs a few seconds after host startup, queries the storage for orphaned locks (held by a server that crashed), and releases them. ### Options - **`HangfireOptions`** — `UserName` (required, min 3 chars), `Password` (required, min 12 chars — both validated at startup, no safe default), `Route` (default `/jobs`). ## How modules consume Jobs Inject `IJobService` to enqueue: ```csharp public sealed class SendInvoiceEmailCommandHandler(IJobService jobs) : ICommandHandler { public ValueTask Handle(SendInvoiceEmailCommand cmd, CancellationToken ct) { jobs.Enqueue(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None)); return ValueTask.FromResult(Unit.Value); } } ``` Or register a recurring job — the kit's pattern is to resolve `IRecurringJobManager` inside the module's `MapEndpoints` (it's null when jobs are disabled, so guard it): ```csharp var jobManager = endpoints.ServiceProvider.GetService(); if (jobManager is not null) { // Fire at 00:05 UTC on the 1st of every month; the job bills the previous period. jobManager.AddOrUpdate( "billing-monthly-invoices", Job.FromExpression(j => j.RunAsync(CancellationToken.None)), "5 0 1 * *", new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); } ``` The Billing module ships `MonthlyInvoiceJob` (monthly); the Files module ships `PurgeOrphanedFilesJob` (hourly) and `PurgeDeletedFilesJob` (daily, 03:30 UTC); the Auditing module ships `AuditRetentionJob` (cron from `AuditRetentionOptions`, no-op until enabled). ## Configuration ```jsonc { "HangfireOptions": { "UserName": "admin", "Password": "set-via-secrets-min-12-chars", "Route": "/jobs" } } ``` The storage provider falls out of `DatabaseOptions:Provider` — PostgreSQL routes to `Hangfire.PostgreSql`; MSSQL routes to the SQL Server storage. ## How to extend ### Add a new queue Hangfire dispatches by queue name. Register your job with `[Queue("priority")]` and add the queue name to the server's queue array in `AddHeroJobs`: ```csharp services.AddHangfireServer(opts => opts.Queues = new[] { "default", "email", "priority" }); ``` ### Decorate jobs with a custom filter Implement `IServerFilter` and register it in DI; `AddHangfireServer` will pick it up. The kit's filters are templates — read `FshJobFilter.cs` for the lifecycle hooks (`OnPerforming`, `OnPerformed`). ### Use Hangfire's `[AutomaticRetry]` attribute For per-job retry policies: ```csharp [AutomaticRetry(Attempts = 4, OnAttemptsExceeded = AttemptsExceededAction.Fail, DelaysInSeconds = new[] { 30, 120, 600, 3600 })] public sealed class WebhookDispatchJob { /* ... */ } ``` The Webhooks module uses this exact pattern. ## Gotchas - **Hangfire serializes arguments** when enqueuing. Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext. - **`FshJobActivator` creates a new scope per job.** Don't hold scoped state across jobs; each invocation gets a fresh DbContext. - **Dashboard auth is basic.** Use HTTPS in production. Consider IP whitelisting in your reverse proxy in addition. - **Stale-lock cleanup runs after startup.** It defers a few seconds so it doesn't interfere with legitimate startup-time lock acquisition; it's best-effort and logs a warning with the count when it actually releases something. ## Critical files - `src/BuildingBlocks/Jobs/Extensions.cs` - `src/BuildingBlocks/Jobs/Services/HangfireService.cs` - `src/BuildingBlocks/Jobs/FshJobFilter.cs` - `src/BuildingBlocks/Jobs/FshJobActivator.cs` - `src/BuildingBlocks/Jobs/HangfireStaleLockCleanupService.cs` ## Related - [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableJobs` toggle. - [Billing module](/docs/modules/billing/) — `MonthlyInvoiceJob` recurring schedule. - [Files module](/docs/modules/files/) — orphan + retention purge jobs. - [Webhooks module](/docs/modules/webhooks/) — `[AutomaticRetry]` with exponential backoff. --- # Mailing building block > SMTP or SendGrid email abstraction behind a single IMailService, with multi-recipient delivery and HTML body support. Source: https://fullstackhero.net/docs/building-blocks/mailing/ The Mailing block is the email abstraction the kit's transactional emails go through. Identity uses it for email confirmation, password reset, and welcome flows; any other module that needs to send mail does so via `IMailService`. Two implementations ship — SMTP via MailKit / MimeKit, and SendGrid via the official SDK — picked at startup from a single `UseSendGrid` flag. The block sends mail; it does not template it. Build your HTML body in your handler (with Razor, Scriban, or a static string), then pass it to `SendAsync`. Keep templating out of the abstraction so you're free to swap engines without touching every call site. ## What it ships ### Extension - **`AddHeroMailing(services)`** — binds `MailOptions`, registers one process-wide `ISendGridClient` singleton (per-send client construction leaks sockets), and a transient `IMailService` factory that returns `SendGridMailService` when `MailOptions:UseSendGrid` is `true`, otherwise `SmtpMailService`. ### Service interface ```csharp public interface IMailService { Task SendAsync(MailRequest request, CancellationToken ct); } ``` ### Request model `MailRequest` is a constructor-built class — recipients always come as a `Collection`: ```csharp public class MailRequest( Collection to, string subject, string? body = null, string? from = null, string? displayName = null, string? replyTo = null, string? replyToName = null, Collection? bcc = null, Collection? cc = null, IDictionary? attachmentData = null, IDictionary? headers = null); ``` The body is treated as HTML by both implementations (MailKit's `BodyBuilder.HtmlBody`; SendGrid sends it as both plain-text and HTML content). `From`/`DisplayName` on the request override the configured defaults per send. ### Implementations - **`SmtpMailService`** — uses MailKit + MimeKit. Reads `MailOptions:Smtp:*` for host, port, credentials; connects with STARTTLS per send. - **`SendGridMailService`** — uses the SendGrid SDK via the shared `ISendGridClient`. One API call per `MailRequest`; the first `To` address is the primary recipient, `Cc`/`Bcc`/`ReplyTo`/attachments map onto the SendGrid message. ### Options - **`MailOptions`** — `From`, `DisplayName`, `UseSendGrid` (bool), `Smtp` (`Host` / `Port` / `UserName` / `Password`), `SendGrid` (`ApiKey` plus optional `From` / `DisplayName` overrides). ## How modules consume Mailing Inject `IMailService` and call `SendAsync` — or better, do what Identity does and push the send onto the Hangfire `email` queue so a slow SMTP server never blocks the request: ```csharp var mailRequest = new MailRequest( new Collection { user.Email }, "Confirm Your Email Address", emailBody); jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); ``` Identity uses this shape for its email flows: email confirmation, password reset, and the welcome mail. ## Configuration ### SMTP ```jsonc { "MailOptions": { "From": "no-reply@example.com", "DisplayName": "fullstackhero", "UseSendGrid": false, "Smtp": { "Host": "smtp.example.com", "Port": 587, "UserName": "smtp-user", "Password": "set-via-secrets" } } } ``` ### SendGrid ```jsonc { "MailOptions": { "From": "no-reply@example.com", "DisplayName": "fullstackhero", "UseSendGrid": true, "SendGrid": { "ApiKey": "set-via-secrets" } } } ``` ## How to extend ### Add an SES (or Mailgun, Postmark…) provider Implement `IMailService` against the SDK of your choice and register it in place of the existing implementations. Both `SmtpMailService` and `SendGridMailService` are templates of "translate `MailRequest` to the provider's API." ### Add templating Build a separate `IMailTemplateRenderer` service. Resolve a Razor or Scriban template, render to HTML, then call `IMailService.SendAsync`. Keep templating outside `IMailService` so you can swap providers without rewriting templates. ### Add a retry policy `Microsoft.Extensions.Http.Resilience` (already a dep of the Web block) lets you wrap any `HttpClient` (SendGrid uses one) with a Polly v8 pipeline. For SMTP, wrap `SmtpMailService` with a decorator that retries on transient failures (timeout, 4xx with `try-again` codes). ## Gotchas - **`MailRequest.To` is always a `Collection`** — even for one recipient. Note the SendGrid path builds a *single* email to `To[0]` (extra recipients ride along only as Cc/Bcc); the SMTP path addresses everyone in `To`. Bulk lists of 100+ recipients should be split into multiple `SendAsync` calls so a single bad address doesn't fail the whole batch. - **No built-in templating.** This is by design. If you need it, ship `IMailTemplateRenderer` next to your handlers. - **`SmtpMailService` opens a fresh connection per send.** Heavy traffic? Switch to SendGrid or wrap with a pool. The kit doesn't ship a long-lived SMTP client. - **SendGrid charges per API call.** Bulk personalisation via the SendGrid Personalisations API is faster and cheaper for newsletter-style use. The kit's wrapper is the transactional path; replace it for marketing. - **Plaintext credentials in `appsettings.json`** are a footgun. Use environment variables, `user-secrets`, or your cloud secrets manager. Never check `MailOptions:Smtp:Password` into git. ## Critical files - `src/BuildingBlocks/Mailing/Extensions.cs` - `src/BuildingBlocks/Mailing/Services/IMailService.cs` - `src/BuildingBlocks/Mailing/Services/SmtpMailService.cs` - `src/BuildingBlocks/Mailing/Services/SendGridMailService.cs` - `src/BuildingBlocks/Mailing/MailOptions.cs` ## Related - [Web](/docs/building-blocks/web/) — `FshPlatformOptions.EnableMailing` toggle. - [Identity module](/docs/modules/identity/) — the biggest consumer (confirmation, reset, welcome). - [Notifications module](/docs/modules/notifications/) — in-app inbox; pair it with email for double-channel delivery. --- # Persistence building block > EF Core base context with auto-applied multitenancy and soft-delete filters, the Specification pattern, audit + domain-event interceptors, and per-tenant connection strings. Source: https://fullstackhero.net/docs/building-blocks/persistence/ The Persistence block is the EF Core configuration layer every module sits on top of. It ships a `BaseDbContext` that auto-applies tenant isolation and the soft-delete query filter, the `Specification` pattern for composable queries, two `ISaveChangesInterceptor` implementations (audit + domain events), and a `DatabaseOptions` switch between PostgreSQL and SQL Server. Around **400-600 lines of code**. Make your module's DbContext inherit `BaseDbContext`, call `base.OnModelCreating(builder)` **last** in your override, and every tenant-aware entity is filtered automatically. No per-entity opt-in. ## What it ships ### Extensions - **`AddHeroDatabaseOptions(services, configuration)`** — binds `DatabaseOptions` with validate-on-start, registers `AuditableEntitySaveChangesInterceptor` + `DomainEventsInterceptor` as `ISaveChangesInterceptor`, `TimeProvider.System`, and a startup logger that prints the active provider. - **`AddHeroDbContext(services)`** — registers a DbContext via `ConfigureHeroDatabase` (provider switching from `DatabaseOptions`) with every registered `ISaveChangesInterceptor` attached. ### Base type - **`BaseDbContext`** — extends Finbuckle's `MultiTenantDbContext`. In `OnModelCreating` it appends the named `SoftDelete` query filter to every `ISoftDeletable` entity, then calls `ApplyTenantIsolationByDefault()` which marks **every** entity `IsMultiTenant()` unless it implements `IGlobalEntity` — tenant isolation is default-ON, opt-out only. In `SaveChangesAsync` it sets `TenantNotSetMode.Overwrite` so unsaved entities without an explicit tenant inherit the current one. It also honours a per-tenant `ConnectionString` from `AppTenantInfo` in `OnConfiguring`. ### Specification pattern - **`Specification`** — composable query base with protected `Where()`, `Include()` / `Include(string)`, `OrderBy()` / `OrderByDescending()` / `ThenBy()` / `ThenByDescending()`, and `ApplySortingOverride()` (whitelist-mapped client sort expressions like `"Name,-CreatedOn"` — no reflection). `AsNoTracking` is on by default. Multi-`Where` combines via expression-tree AND. - **`SpecificationOfTResult` (`Specification`)** — projection variant with a `Select` expression. - **`SpecificationExtensions.ApplySpecification(...)`** — turns a spec (entity or projected) into an `IQueryable`. ### Interceptors - **`AuditableEntitySaveChangesInterceptor`** — fills in `CreatedOnUtc`/`CreatedBy` (Added) and `LastModifiedOnUtc`/`LastModifiedBy` (Modified, including owned-entity changes) from `ICurrentUser` + `TimeProvider`. It also **converts hard deletes into soft deletes**: an `ISoftDeletable` entry in Deleted state is flipped to Modified with `IsDeleted`/`DeletedOnUtc`/`DeletedBy` set (and cascade-deleted owned references restored so the UPDATE doesn't null their columns). - **`DomainEventsInterceptor`** — after `SaveChangesAsync` succeeds, collects events from tracked `IHasDomainEvents` entities (clearing each list), then publishes each through Mediator. Handler failures are logged, not rethrown — the save is already committed; use the outbox for guaranteed delivery. ### Model-builder helpers - **`ApplyTenantIsolationByDefault(modelBuilder)`** — marks every keyed, non-owned entity that isn't `IGlobalEntity` (and isn't already explicitly marked) as `IsMultiTenant().AdjustUniqueIndexes()`. Must run after `ApplyConfigurationsFromAssembly` so unique indexes exist to widen. - **`AppendGlobalQueryFilter(modelBuilder, filterName, expr)`** — registers a **named** filter on every entity implementing `TInterface`; named filters compose with Finbuckle's anonymous tenant filter via AND. ### Options - **`DatabaseOptions`** (lives in `FSH.Framework.Shared`) — `Provider` (`POSTGRESQL` default or `MSSQL`, matched case-insensitively), `ConnectionString`, `MigrationsAssembly`. An empty connection string fails validation at startup. ## How modules consume it ```csharp // Your module's DbContext (this is the real CatalogDbContext) public sealed class CatalogDbContext : BaseDbContext { public const string Schema = "catalog"; public CatalogDbContext( IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IOptions settings, IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { } public DbSet Brands => Set(); public DbSet Products => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly); base.OnModelCreating(modelBuilder); // CRITICAL — must be last } } // Register in the module's ConfigureServices builder.Services.AddHeroDbContext(); ``` Anywhere you need a tenant-scoped query, just `await db.Products.ToListAsync(ct)` — the filter is applied transparently. For composable queries: ```csharp public sealed class ActiveProductsByBrandSpec : Specification { public ActiveProductsByBrandSpec(Guid brandId) { Where(p => p.BrandId == brandId && p.IsActive); Include(p => p.Images); OrderByDescending(p => p.CreatedAtUtc); } } // Handler var products = await _db.Products .ApplySpecification(new ActiveProductsByBrandSpec(brandId)) .ToListAsync(ct).ConfigureAwait(false); ``` ## Configuration ```jsonc { "DatabaseOptions": { "Provider": "PostgreSQL", "ConnectionString": "Host=localhost;Port=5432;Database=fsh;Username=postgres;Password=postgres", "MigrationsAssembly": "FSH.Starter.Migrations.PostgreSQL" } } ``` `OptionsBuilderExtensions.ConfigureHeroDatabase()` picks `UseNpgsql()` or `UseSqlServer()` from the `Provider` value. Tenant-specific connection strings, when set on `AppTenantInfo`, override the default at request time via Finbuckle. ## How to extend ### Add a new interceptor ```csharp public sealed class FullTextSearchInterceptor : SaveChangesInterceptor { public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken ct = default) { // rewrite a generated tsvector column based on Body, e.g. return base.SavingChangesAsync(eventData, result, ct); } } // register services.AddScoped(); ``` Then attach it to whichever `DbContext` should run it via the `AddDbContext` options. ### Add a named query filter ```csharp modelBuilder.Entity().HasQueryFilter("ActiveOnly", e => e.IsActive); // then to bypass just that filter at a call site: db.MyEntities.IgnoreQueryFilters(["ActiveOnly"]) ``` The kit already uses this pattern for soft delete: the filter is registered under the stable name `QueryFilters.SoftDelete`, so trash views and restore handlers call `IgnoreQueryFilters([QueryFilters.SoftDelete])` and the tenant filter stays in force. Bare `IgnoreQueryFilters()` strips **everything** — including tenant isolation — so reserve it for root-gated cross-tenant queries and re-filter explicitly. ## Gotchas - **`base.OnModelCreating(builder)` must come last** in your subclass's override. The base applies the global tenant filter + soft-delete filter; if you run it first your per-entity configurations can stomp on those filters (or vice versa). - **`Specification.AsNoTracking` is `true` by default.** This is a deliberate guard against unbounded tracking of large query results. Call out tracked queries explicitly if you need write semantics. - **Multi-`Where()` calls combine via AND**, not OR. The base doesn't compose `||`; if you need OR, write it inside a single expression. - **`TenantNotSetMode.Overwrite`** in `SaveChangesAsync` means saving an entity without a tenant set will be silently filled in with the current tenant. That's intentional — without it, every handler would have to remember `entity.TenantId = current.GetTenant()` — but it does mean entities created in a null-tenant context get assigned to whichever tenant the current request belongs to. Don't run write paths from background threads without restoring tenant context first. - **Entity ids are client-generated `Guid.CreateVersion7()`** (set in the aggregate's static factory), not database-generated. UUIDv7 keeps inserts index-friendly while letting the domain own the id. - **Child entities reached only via a parent nav collection need `Property(x => x.Id).ValueGeneratedNever()`** in their configuration. Without it, EF sees the factory-assigned non-default `Guid` and tracks the child as Modified instead of Added — the insert silently never happens. `ProductImage`, `TicketComment`, and Chat's `MessageReaction`/`MessageMention` all carry this config for exactly that reason. ## Critical files - `src/BuildingBlocks/Persistence/Context/BaseDbContext.cs` - `src/BuildingBlocks/Persistence/Specifications/Specification.cs` - `src/BuildingBlocks/Persistence/PersistenceExtensions.cs` - `src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs` ## Related - [Core](/docs/building-blocks/core/) — `BaseEntity`, `IHasTenant`, `ISoftDeletable`, `IGlobalEntity`. - [Multitenancy module](/docs/modules/multitenancy/) — the Finbuckle setup `BaseDbContext` builds on. - [Architecture: multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — `IgnoreQueryFilters()` (all filters) vs `IgnoreQueryFilters([QueryFilters.SoftDelete])` (named filter only) discipline. --- # Quota building block > Per-tenant resource limits with Valkey or in-memory counters, gauge-based live usage, calendar-month windows, and HTTP middleware enforcement. Source: https://fullstackhero.net/docs/building-blocks/quota/ The Quota block enforces per-tenant resource limits. Storage bytes used, number of active users, API calls per month, anything you care to meter. It ships Valkey-backed counters (Valkey is a Redis-compatible, BSD-licensed Redis fork) with an in-memory fallback (for dev/test only), plan-driven limits with per-tenant overrides, an `IQuotaGaugeProvider` hook for modules that expose live counts (the Identity module exposes user count this way), and a request middleware that returns 429 when a tenant blows past its plan. Most tenants are on a plan. Plans live in `appsettings:QuotaOptions:Plans`. A tenant's `AppTenantInfo.QuotaLimits` is the per-tenant override dict — set values there only when you've negotiated a bespoke contract. ## What it ships ### Extensions - **`AddHeroQuotas(services, configuration)`** — registers an `IQuotaService` based on `QuotaOptions:Enabled` and whether Valkey is configured. Disabled → `NoopQuotaService`. Enabled + Valkey → `RedisQuotaService`. Enabled + no Valkey → `InMemoryQuotaService` (per-process, dev/test only). Also registers `QuotaPlanResolver` + `QuotaEnforcementMiddleware`. - **`UseHeroQuotas(app)`** — inserts the enforcement middleware into the pipeline (after authentication **and the rate limiter** — rate-limited requests shouldn't burn quota — before authorization). ### Service interfaces - **`IQuotaService`** — `CheckAsync`, `RecordAsync`, `CheckAndRecordAsync`, `GetCurrentAsync` (all keyed by `string tenantId` + `QuotaResource`). The atomic check-and-record is the one you want for any "this counts towards the quota" path; `RecordAsync` accepts negative amounts for refunds. - **`IQuotaGaugeProvider`** — extensibility hook for modules that expose live usage: a `Resource` property plus `GetCurrentAsync(tenantId, ct)`. Gauge-based resources (`Users`, `ActiveFeatureFlags`) are resolved on demand through these. ### Implementations - **`RedisQuotaService`** — string counters keyed `quota:{tenantId}:{resource}:{YYYYMM}` for periodic resources (`ApiCalls`), `quota:{tenantId}:{resource}` for perpetual ones (`StorageBytes`, which accumulates until explicitly decremented). Periodic keys get a TTL aligned to the start of the next UTC month. - **`InMemoryQuotaService`** — in-process counters. Single-instance only. - **`NoopQuotaService`** — drop-in for when quotas are off; every check passes. ### Models - **`QuotaResource`** enum — `ApiCalls`, `StorageBytes`, `Users`, `ActiveFeatureFlags`. - **`QuotaCheckResult`** — `Allowed`, `Resource`, `CurrentUsage`, `Limit`, `ResetAtUtc` (null for non-periodic resources). - **`QuotaPlanResolver`** — `ResolveLimit(tenant, resource)` returns the effective limit: the tenant's own `QuotaLimits` override wins, then the tenant's plan from `QuotaOptions:Plans`, then the `DefaultPlan`, then `long.MaxValue` (no limit). Negative values normalize to unlimited. ### Middleware - **`QuotaEnforcementMiddleware`** — charges **one `ApiCalls` unit per request** via `CheckAndRecordAsync`. Skips health/metrics paths and requests with no resolved tenant. On exhaustion it returns `429 Too Many Requests` as RFC 9457 ProblemDetails with a `Retry-After` header and `resource`/`limit`/`currentUsage`/`resetAtUtc` extensions, and flags the request (`HttpContextItemKeys.QuotaRejected`) so the auditing module can tag it `OutOfQuota` without a hard dependency. ### Options - **`QuotaOptions`** — `Enabled` (bool, default true), `Redis` (connection string), `DefaultPlan` (string; e.g. `free`), `Plans` (Dict — `Plans:free:StorageBytes = 1073741824`), `ExemptRootTenant` (bool, default true). ## How modules consume Quota The Storage block wraps `IStorageService` with `QuotaMeteredStorageService`: every upload charges the tenant's `StorageBytes` meter (and a failed write rolls the charge back), every delete refunds the object's size. An exhausted storage quota surfaces as **`507 Insufficient Storage`**, not 429: ```csharp var check = await _quotas .CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, cancellationToken) .ConfigureAwait(false); if (!check.Allowed) { throw new CustomException( $"Storage quota exceeded ({check.CurrentUsage}/{check.Limit} bytes).", errors: null, HttpStatusCode.InsufficientStorage); } ``` The Identity module exposes the live user count as a gauge (`UserCountQuotaGaugeProvider`): ```csharp internal sealed class UserCountQuotaGaugeProvider(UserManager userManager) : IQuotaGaugeProvider { public QuotaResource Resource => QuotaResource.Users; public async ValueTask GetCurrentAsync(string tenantId, CancellationToken ct = default) => await userManager.Users .IgnoreQueryFilters() .CountAsync(u => EF.Property(u, "TenantId") == tenantId, ct) .ConfigureAwait(false); } ``` The quota service compares the gauge value against the plan limit on every `CheckAsync`. ## Configuration ```jsonc { "QuotaOptions": { "Enabled": true, "Redis": "localhost:6379", "DefaultPlan": "free", "ExemptRootTenant": true, "Plans": { "free": { "ApiCalls": 100000, "StorageBytes": 1073741824, // 1 GB "Users": 5 }, "growth": { "ApiCalls": 1000000, "StorageBytes": 53687091200, // 50 GB "Users": 50 } } } } ``` Plan keys map to `QuotaResource` enum members; `-1` (or `long.MaxValue`) means unlimited. Per-tenant overrides go on `AppTenantInfo.QuotaLimits`. Anything you set there wins over the plan's default. ## How to extend ### Add a new gauge ```csharp public sealed class OpenTicketsGaugeProvider(TicketsDbContext db) : IQuotaGaugeProvider { public QuotaResource Resource => QuotaResource.OpenTickets; public async ValueTask GetCurrentAsync(string tenantId, CancellationToken ct = default) => await db.Tickets.LongCountAsync(t => t.Status != TicketStatus.Closed, ct).ConfigureAwait(false); } services.AddScoped(); ``` …and add `OpenTickets` to the `QuotaResource` enum (in `FSH.Framework.Shared`) + the `Plans` dict in config. The Redis service resolves whichever provider matches the requested resource. ### Exempt a path from the per-request meter The middleware's path exemptions (`/health*`, `/metrics`, …) are a hardcoded list in `QuotaEnforcementMiddleware.IsExempt` — there is no per-endpoint attribute today. To exempt more paths, extend that check; for non-`ApiCalls` resources nothing is enforced unless your code calls `CheckAndRecordAsync`. ### Use a different period The monthly window is implemented inside `RedisQuotaService` (`BuildCounterKey` embeds `YYYYMM`; `GetPeriodResetUtc` returns the first moment of the next UTC month). A different window means a different `IQuotaService` implementation — the interface doesn't care about periods. ## Gotchas - **Only `ApiCalls` is enforced automatically.** The middleware meters one unit per request; every other resource is enforced only where code calls into `IQuotaService` (storage does, via the metered decorator). The `Users` gauge reports usage — nothing blocks user creation against it out of the box. - **Period is calendar-month, UTC — and only for `ApiCalls`.** Counters reset at midnight UTC on the 1st; cross-timezone customers see "your usage reset" at 4 PM PT on the 31st. `StorageBytes` never resets — it's a perpetual counter that uploads increment and deletes refund. - **Gauges are read on-demand.** Each `CheckAsync` invokes the gauge providers, which usually do a DB query. Cache the results inside the gauge if the data is expensive to compute and slightly stale is fine. - **`InMemoryQuotaService` is not cluster-safe.** Two instances of the API hold separate counters. Use Valkey (or any distributed cache) in production. - **Root tenant exemption is global.** With `ExemptRootTenant = true`, the root/platform tenant has unlimited everything. Don't disable this in production unless you have a reason — internal admin actions tend to exceed all the limits you set for paying customers. - **Valkey TTL.** Period keys carry a TTL aligned to the period end. Clock skew between instances can produce off-by-one errors at the period boundary. Use NTP. ## Critical files - `src/BuildingBlocks/Quota/Extensions.cs` - `src/BuildingBlocks/Quota/IQuotaService.cs` - `src/BuildingBlocks/Quota/RedisQuotaService.cs` - `src/BuildingBlocks/Quota/QuotaPlanResolver.cs` - `src/BuildingBlocks/Quota/QuotaEnforcementMiddleware.cs` ## Related - [Storage](/docs/building-blocks/storage/) — uses the metered decorator for `StorageBytes`. - [Identity module](/docs/modules/identity/) — exposes the user-count gauge. - [Billing module](/docs/modules/billing/) — plan keys here must match plan keys there. --- # Shared building block > Multi-tenancy types, permission registry, claim/role/action/resource constants, audit attributes, and shared DTOs used by every module. Source: https://fullstackhero.net/docs/building-blocks/shared/ The Shared block carries the **typed constants and DTOs every module references** — tenant info, the permission registry, claim/action/resource string constants, audit attributes, and the `DatabaseOptions` consumed by Persistence. It exists so modules can reference one block of shared types instead of either copy-pasting constants or pulling in the heavier Web / Persistence runtime blocks. New permission? Add it to `PermissionConstants`. New claim type? Add it to `ClaimConstants`. New audit attribute? Add it to `AuditAttributes`. The Shared block is the canonical home for "things every module needs to agree on." ## What it ships ### Multitenancy - **`AppTenantInfo`** — extends Finbuckle's `TenantInfo` + implements `IAppTenantInfo`. Carries `Id`, `Identifier`, `Name`, `ConnectionString`, `AdminEmail`, `IsActive`, `ValidUpto`, `Issuer`, `Plan`, and `QuotaLimits` (a `Dictionary` of per-tenant overrides). Methods: `AddValidity(months)`, `SetValidity(DateTime)` (**forward-only** — backdating throws), `Activate()` / `Deactivate()` (both throw for the root tenant). New tenants get a 1-month demo validity by default. - **`IAppTenantInfo`** — contract for tenant info; the kit references the interface where it can, allowing custom subclasses if you ever need them. - **`MultitenancyConstants`** — well-known values: the `Root` tenant (`Id = "root"`, name, admin email), the `tenant` resolution identifier, and the tenant schema name. - **`ITenantInitialPasswordBuffer`** — singleton interface for buffering the operator-supplied tenant admin password between the create-tenant handler and the background seed step. Implementation lives in the Multitenancy module. ### Identity + permissions - **`PermissionConstants`** — the central registry. Static `Register(IEnumerable)` is called by each module during `ConfigureServices` (duplicates by `Name` are skipped). Properties: `All`, `Root` (where `IsRoot`), `Admin` (everything non-root), `Basic` (where `IsBasic`). - **`FshPermission(Description, Action, Resource, IsBasic = false, IsRoot = false)`** — immutable record. Computed `Name` is the canonical permission string: `Permissions.{Resource}.{Action}` (e.g. `Permissions.Users.Create`). - **`PermissionConstants.RequiredPermissionPolicyName`** — the name of the authorization policy that evaluates `.RequirePermission()` metadata. The Identity module registers the policy **and sets it as both `DefaultPolicy` and `FallbackPolicy`** — that double assignment matters (see gotchas). - **`SystemPermissions.All`** — platform permissions registered by `AddHeroPlatform` before any module runs. - **`RoleConstants`** — well-known role names (`Admin`, `Basic`) plus `DefaultRoles` / `IsDefault(role)`. - **`ClaimConstants`** — claim type names (`tenant`, `fullName`, `permission`, `image_url`, `ipAddress`, `exp`, and the impersonation actor claims `act_sub` / `act_tenant`). - **`CustomClaims`** — legacy alias set covering the same core claim names. - **`ResourceConstants`** — framework resource names (`Tenants`, `Users`, `Roles`, `UserRoles`, `RoleClaims`, `AuditTrails`, `Dashboard`, `Hangfire`). Modules define their own resource strings in their contracts (e.g. `Catalog.Products`). - **`ActionConstants`** — action names (`View`, `Search`, `Create`, `Update`, `Delete`, `Export`, `Generate`, `Clean`, `UpgradeSubscription`). ### Claims + authorization - **`ClaimsPrincipalExtensions`** — extracts user id, tenant, email, etc. from a `ClaimsPrincipal`. Used in middleware and handlers. - **`RequiredPermissionAttribute`** — implements **`IRequiredPermissionMetadata`** (a `HashSet RequiredPermissions`). This interface is what the authorization handler keys off — it must exist exactly once, in `FSH.Framework.Shared.Identity.Authorization`. - **`EndpointExtensions.RequirePermission(permission, params additionalPermissions)`** — the canonical fluent helper for minimal-API endpoints; it attaches a `RequiredPermissionAttribute` as endpoint metadata. Permissions are plain strings (modules expose them as `const string`s in their contracts). ### Persistence shared - **`DatabaseOptions`** — defined here, consumed by the Persistence block. `Provider` (string), `ConnectionString` (string, validated required), `MigrationsAssembly` (string). - **`DbProviders.PostgreSQL`** + **`DbProviders.MSSQL`** — provider name constants. ### Auditing markers - **`AuditAttributes`** — `[AuditIgnore]` (exclude a property from audit diffs/payloads) and `[AuditSensitive(hash, redact)]` (mask or hash the value when serialized). The Auditing module reads these when capturing changes. - **`HttpContextItemKeys`** — well-known `HttpContext.Items` keys building-block middleware uses to signal the audit pipeline without depending on it. Currently: `QuotaRejected` (set by the quota middleware on a 429). ### Storage + quota DTOs - **`FileUploadRequest`**, **`PresignedUploadUrl`**, **`StoredObjectMetadata`** — the storage transfer types shared between the Storage block and the Files module. - **`QuotaResource`** — enum (`ApiCalls`, `StorageBytes`, `Users`, `ActiveFeatureFlags`). New quota dimensions get added here. ## How modules consume Shared A typical module declares string constants for endpoint wiring plus an `FshPermission` list for the registry — this is Catalog's real shape: ```csharp // Modules.Catalog.Contracts/Authorization/CatalogPermissions.cs public static class CatalogPermissions { public static class Products { public const string Resource = "Catalog.Products"; public const string View = $"Permissions.{Resource}.View"; public const string Create = $"Permissions.{Resource}.Create"; // ... } public static IReadOnlyList All { get; } = [ new("View Products", ActionConstants.View, Products.Resource, IsBasic: true), // ... ]; } // CatalogModule.ConfigureServices PermissionConstants.Register(CatalogPermissions.All); ``` Endpoints reference the string constants via the fluent helper: ```csharp endpoints.MapPost("/products", handler) .RequirePermission(CatalogPermissions.Products.Create); ``` ## How to extend ### Add a new resource Add a constant to `ResourceConstants`, define a `FshPermission` set for the resource in your module's `Contracts.Authorization`, register it during module startup. Done. ### Add a new well-known claim Add a name to `ClaimConstants` (or `CustomClaims` for kit-specific values). Update `TokenService` to issue the claim, update `ClaimsPrincipalExtensions` to read it. The Identity module is the place for both changes. ### Add a new quota dimension Extend `QuotaResource` with the new value, expose a gauge provider (see [Quota](/docs/building-blocks/quota/)), update plan config to set defaults. ## Gotchas - **`PermissionConstants.Register` dedupes by `Name`.** Calling it twice with the same permission is a no-op. There is no way to **remove** a permission — once registered, it stays for the process lifetime. - **`IRequiredPermissionMetadata` must never be duplicated.** The authorization handler discovers permissions via this interface; a copy-pasted duplicate in another namespace means endpoint metadata implements the *wrong* interface and **every** `.RequirePermission()` gate silently stops enforcing. - **The permission policy must stay both DefaultPolicy AND FallbackPolicy.** A group-level `.RequireAuthorization()` attaches the *default* policy, which suppresses the fallback — if the default were only "authenticated", `.RequirePermission()` would silently fail open (this was a real bug, fixed in PR #1290). The Identity module assigns the `RequiredPermission` policy to both; don't undo that. - **`AppTenantInfo.SetValidity` is forward-only.** It throws "Subscription cannot be backdated" — for initial/explicit/backdated sets, assign `ValidUpto` directly. - **`AppTenantInfo.Plan` is nullable.** When null, plan resolution falls back to `QuotaOptions:DefaultPlan` (`free` by default). Set this explicitly on every tenant unless you want the default. - **`AppTenantInfo.QuotaLimits` is a per-tenant override.** If a resource is present in the dict, it wins over the plan's limit. Don't sprinkle overrides; reserve them for negotiated contracts. - **Permissions are strings.** No compile-time safety on typos. Reference the module's constants — never write `"Permissions.X.Y"` literals in handlers or endpoints. ## Critical files - `src/BuildingBlocks/Shared/Multitenancy/AppTenantInfo.cs` - `src/BuildingBlocks/Shared/Identity/PermissionConstants.cs` (also home of the `FshPermission` record) - `src/BuildingBlocks/Shared/Identity/SystemPermissions.cs` - `src/BuildingBlocks/Shared/Identity/Authorization/RequiredPermissionAttribute.cs` (and `IRequiredPermissionMetadata`) - `src/BuildingBlocks/Shared/Auditing/AuditAttributes.cs` ## Related - [Core](/docs/building-blocks/core/) — even more foundational. - [Web](/docs/building-blocks/web/) — `.RequirePermission()` applied to endpoints. - [Quota](/docs/building-blocks/quota/) — `QuotaResource` enum lives here, the enforcement runs there. - [Identity module](/docs/modules/identity/) — registers `IdentityPermissions.All` against `PermissionConstants`. --- # Storage building block > File storage abstraction — local filesystem or S3-compatible (MinIO / AWS S3) — with presigned URLs, tenant isolation, and optional quota metering. Source: https://fullstackhero.net/docs/building-blocks/storage/ The Storage block is the kit's blob-storage abstraction. One interface (`IStorageService`), two implementations — `LocalStorageService` (filesystem) and `S3StorageService` (AWS S3 + MinIO compatible) — with presigned URL support and an optional `QuotaMeteredStorageService` decorator that meters per-tenant usage against the Quota block. Clients PUT directly to S3 / MinIO using a short-lived presigned URL minted by your API. Your API never proxies bytes. Same on the read side — `GenerateDownloadUrlAsync` mints a presigned GET URL the browser hits directly. ## What it ships ### Extensions - **`AddHeroLocalFileStorage(services)`** — registers `LocalStorageService` for `wwwroot`-based file I/O. Useful for dev. - **`AddHeroStorage(services, configuration)`** — reads `Storage:Provider` **eagerly at registration time**. `"s3"` registers a shared `IAmazonS3` client + `S3StorageService`; anything else falls back to `LocalStorageService`. Wraps the chosen provider with `QuotaMeteredStorageService` when `QuotaOptions:Enabled` is true (also read at registration). ### Interface ```csharp public interface IStorageService { // Returns the stored object's relative path/key Task UploadAsync(FileUploadRequest request, FileType fileType, CancellationToken cancellationToken = default) where T : class; Task DownloadAsync(string path, CancellationToken cancellationToken = default); Task ExistsAsync(string path, CancellationToken cancellationToken = default); Task GetSizeAsync(string path, CancellationToken cancellationToken = default); // 0 if missing Task RemoveAsync(string path, CancellationToken cancellationToken = default); Task GenerateUploadUrlAsync( string storageKey, string contentType, long maxBytes, TimeSpan ttl, CancellationToken cancellationToken = default); Task GenerateDownloadUrlAsync( string storageKey, TimeSpan ttl, string? responseContentDisposition = null, CancellationToken cancellationToken = default); Task HeadObjectAsync(string storageKey, CancellationToken cancellationToken = default); string BuildPublicUrl(string storageKey); // durable non-expiring URL (or server-relative path on local) } ``` ### Implementations - **`LocalStorageService`** — stores under `wwwroot/uploads/{owner-type}/{guid}_{sanitized-filename}` and validates extension + size against `FileTypeMetadata.GetRules(fileType)`. Presigning is a **dev-only token fallback**: `GenerateUploadUrlAsync` issues a `local://upload/{token}` URL backed by `LocalPresignTokenStore`; `GenerateDownloadUrlAsync` and `BuildPublicUrl` return server-relative `/uploads/...` paths (no signing needed). - **`S3StorageService`** — uses `AWSSDK.S3` with a singleton `IAmazonS3` client. A custom `ServiceUrl` (MinIO etc.) switches to path-style addressing per `ForcePathStyle`; presigned PUT/GET URLs come from the SDK's request signer. - **`QuotaMeteredStorageService`** — decorator. `CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, ct)` on upload (throws 507 when exceeded, rolls back the charge if the write fails); refunds the object's size on `RemoveAsync`. Requests with no resolved tenant pass through unmetered. ### Request / response - **`FileUploadRequest`** — `FileName`, `ContentType`, `Data`. - **`FileDownloadResponse`** — `Stream`, `ContentType`, `FileName`, `ContentLength?`. - **`PresignedUploadUrl`** — `Url`, `RequiredHeaders` (headers the browser must send verbatim, e.g. Content-Type), `ExpiresAt`. - **`StoredObjectMetadata`** — `SizeBytes`, `ContentType`, `LastModified`, `ETag` (from HEAD). - **`FileType`** enum — `Image`, `Document`, `Pdf`; `FileTypeMetadata` maps each to an extension whitelist + max size. ### Options - **`S3StorageOptions`** — `Bucket`, `Region`, `Prefix`, `PublicRead` (default true), `PublicBaseUrl` (for non-expiring public URLs), `ServiceUrl` (custom endpoint for MinIO etc.), `AccessKey` / `SecretKey` (leave empty to use the AWS SDK credential chain), `ForcePathStyle` (only applies when `ServiceUrl` is set). ## How modules consume Storage The Files module is the canonical consumer: its request-upload-url endpoint mints presigned PUT URLs, the finalize handler verifies size + content type via `HeadObjectAsync` before a row leaves `PendingUpload`, and purge jobs clear orphans. Direct usage (without the Files module) looks like: ```csharp public sealed class UploadAvatarHandler(IStorageService storage, ICurrentUser current) : ICommandHandler // returns the public URL { public async ValueTask Handle(UploadAvatarCommand cmd, CancellationToken ct) { var storageKey = await storage.UploadAsync( new FileUploadRequest { FileName = $"{current.GetUserId()}-avatar.png", ContentType = "image/png", Data = cmd.Data, }, FileType.Image, ct).ConfigureAwait(false); return storage.BuildPublicUrl(storageKey); } } ``` The generic `T` names the owning type — local storage uses it as the folder segment (`uploads/useravatar/...`). ## Configuration ```jsonc { "Storage": { "Provider": "s3", // or "local" "S3": { "Bucket": "fsh-uploads", "ServiceUrl": "http://minio:9000", // omit for AWS S3 default "Region": "us-east-1", "ForcePathStyle": true, // required for MinIO "AccessKey": "minioadmin", "SecretKey": "set-via-secrets", "PublicBaseUrl": "https://cdn.example.com" // for non-expiring public URLs } } } ``` ## How to extend ### Add Azure Blob Storage Implement `IStorageService` against the Azure SDK and register it in place of the S3 implementation: ```csharp services.AddSingleton(); ``` Wrap with the quota decorator if you want: ```csharp services.Decorate(); ``` (The kit doesn't ship Scrutor; copy its `Decorate` pattern or register manually with `IConfigureOptions`.) ### Use AWS IAM role for credentials Omit `AccessKey` and `SecretKey` from `S3StorageOptions`; the AWS SDK falls through to the instance-profile / EKS-pod-identity / web-identity chain automatically. ### Scan files post-upload The Files module ships an `IFileScanner` hook. If you're using `IStorageService` directly without the Files module, kick off the scan in the same handler that finalises the upload. ## Gotchas - **MinIO needs `ForcePathStyle = true`.** Virtual-hosted-style addressing puts the bucket name in the subdomain, which MinIO can't service without DNS gymnastics. The option defaults to `false` and only takes effect when `ServiceUrl` is set — set it explicitly for MinIO and other self-hosted S3-compatible services. - **Presigned URLs have a TTL.** Once it expires, the URL is dead. Use `BuildPublicUrl` for non-expiring public URLs (and a bucket policy that grants public-read on that prefix). - **`QuotaMeteredStorageService` is scoped.** It depends on `IQuotaService` which is scoped per request. Don't resolve it from a singleton or a hosted service without creating a scope. - **Local storage paths are not tenant-segmented.** Files land under `wwwroot/uploads/{owner-type}/`, and anything `BuildPublicUrl` points at is served statically without policy enforcement. Don't use `LocalStorageService` in multi-tenant production — it exists for dev and tests. - **`AddHeroStorage` reads configuration eagerly.** The provider choice and the quota toggle are evaluated once, at registration. Integration tests that swap config after host build must re-register/rewire `IStorageService` post-registration — changing `Storage:Provider` later does nothing. ## Critical files - `src/BuildingBlocks/Storage/Extensions.cs` - `src/BuildingBlocks/Storage/Services/IStorageService.cs` - `src/BuildingBlocks/Storage/Local/LocalStorageService.cs` - `src/BuildingBlocks/Storage/S3/S3StorageService.cs` - `src/BuildingBlocks/Storage/S3/S3StorageOptions.cs` ## Related - [Files module](/docs/modules/files/) — the policy + lifecycle layer on top of this block. - [Quota](/docs/building-blocks/quota/) — usage metering wrapper. - [Catalog module](/docs/modules/catalog/) — product images flow through here. --- # Web building block > The composition root for the host — AddHeroPlatform / UseHeroPlatform, module loader, global exception handler, validation pipeline, CurrentUser middleware, and observability. Source: https://fullstackhero.net/docs/building-blocks/web/ The Web block is the host-side composition layer that turns a `IHostApplicationBuilder` into a fullstackhero application. Two extension methods — `AddHeroPlatform` and `UseHeroPlatform` — wire every cross-cutting concern: logging, OpenTelemetry, exception handling, CORS, OpenAPI + Scalar, API versioning, authentication, authorization, validation pipeline, idempotency, feature flags, SSE, SignalR realtime, rate limiting, module loading, and current-user resolution. Roughly **2,000+ lines of code across 40+ files**. Your composition root has two real lines — `builder.AddHeroPlatform(o => ...)` and `app.UseHeroPlatform(p => ...)`. Everything else flows from those. Options toggles (`EnableCaching`, `EnableJobs`, etc.) decide which optional blocks come along for the ride. ## What it ships ### Composition methods - **`AddHeroPlatform(IHostApplicationBuilder, Action?)`** — registers all platform services. Hooks every optional block (caching, jobs, mailing, feature flags, idempotency, SSE, realtime, quotas) behind a boolean toggle in `FshPlatformOptions`. - **`UseHeroPlatform(WebApplication, Action?)`** — wires the middleware pipeline in the right order; mounts modules' endpoints and middleware via the `ModuleLoader`. ### Module discovery + loading - **`IModule`** interface — every module implements it: - `ConfigureServices(IHostApplicationBuilder)` — DI registration phase - `MapEndpoints(IEndpointRouteBuilder)` — endpoint registration phase - `ConfigureMiddleware(IApplicationBuilder)` — middleware insertion phase (default no-op) - **`[assembly: FshModule(typeof(XModule), order)]`** — assembly-level attribute; lower order runs first, ties break alphabetically. - **`ModuleLoader`** — `AddModules(builder, assemblies)` reads the `FshModule` attributes off the passed assemblies (and registers their FluentValidation validators), instantiates each module, and runs `ConfigureServices` in order. `UseModuleMiddlewares()` and `MapModules()` later replay `ConfigureMiddleware` + `MapEndpoints`. ### Cross-cutting - **`GlobalExceptionHandler : IExceptionHandler`** — converts exceptions into `ProblemDetails` (RFC 9457): `CustomException` and its subclasses map to their `StatusCode` with `ErrorMessages` in an `errors` extension; FluentValidation's `ValidationException` becomes a 400 with a per-property errors dictionary; `UnauthorizedAccessException` → 401, `KeyNotFoundException` → 404, `BadHttpRequestException` keeps its own status. Everything else is a generic 500 with the real message withheld (in every environment). Every response carries `traceId` + `correlationId` extensions. - **`ValidationBehavior`** — Mediator pipeline behavior that runs FluentValidation against the request before the handler. - **`CurrentUserMiddleware`** — registered last in the pipeline (after authorization) to populate `ICurrentUser` from the authenticated `ClaimsPrincipal` and tag the current OTel activity with user/tenant/correlation ids. - **`MediatorTracingBehavior`** — emits OpenTelemetry activities for every Mediator command/query (registered by `AddHeroOpenTelemetry`). ### Feature toggles (`FshPlatformOptions`) ```csharp builder.AddHeroPlatform(o => { o.EnableCaching = true; // adds HybridCache + Valkey (default off) o.EnableJobs = true; // adds Hangfire (default off) o.EnableMailing = true; // adds IMailService (default off) o.EnableQuotas = true; // adds quota services (default off) o.EnableSse = true; // adds SSE plumbing (default off) o.EnableRealtime = true; // adds SignalR + Redis backplane (default off) o.EnableFeatureFlags = true; // adds Microsoft.FeatureManagement (default off) // EnableCors, EnableOpenApi, EnableOpenTelemetry, EnableIdempotency default to true }); ``` `UseHeroPlatform` has its own `FshPipelineOptions` mirror: `UseCors`, `UseOpenApi`, `ServeStaticFiles`, `MapModules` (default true) and `MapSseEndpoints`, `MapRealtime`, `UseQuotas` (default false) — enabling a service in `AddHeroPlatform` is only half the job, you also flip the matching pipeline toggle. ### Sub-blocks the Web block wires - **`AddHeroLogging`** — Serilog with HTTP context enrichers. - **`AddHeroOpenTelemetry`** — traces + metrics via OTLP; registers `MediatorTracingBehavior` and wires the caching/Hangfire/module sources and meters. - **`AddHeroOpenApi`** — OpenAPI documents + Scalar UI at `/scalar`; `OpenApiOptions` (Title, Description, Versions, Contact, License). - **`AddHeroVersioning`** — `Asp.Versioning` with **URL-segment** versioning only (`api/v{version}/…`), default v1, assumed when unspecified. - **`AddHeroIdempotency`** — `IdempotencyEndpointFilter` + `IdempotencyOptions` (`HeaderName` default `Idempotency-Key`, `DefaultTtl` 24h, `MaxKeyLength` 128); replay protection via distributed cache. - **`AddHeroFeatureFlags`** — `Microsoft.FeatureManagement` with the `TenantFeatureFilter` for per-tenant overrides and a `FeatureGateEndpointFilter` for endpoints. - **`AddHeroSse`** — Server-Sent Events plumbing (`SseConnectionManager`, token service, endpoints via `MapHeroSseEndpoints`). - **`AddHeroRealtime`** — SignalR (`AppHub` at `/api/v1/realtime/hub` + presence endpoint) with a Redis backplane when `CachingOptions:Redis` is set. - **`UseHeroSecurityHeaders`** — security-headers middleware driven by `SecurityHeadersOptions` (CSP, HSTS, X-Frame-Options, X-Content-Type-Options…). - **`AddHeroResilience`** (on `IHttpClientBuilder`) — standard Polly resilience pipeline for outbound HTTP, tuned by `HttpResilienceOptions`. - **`AddHeroRateLimiting` / `UseHeroRateLimiting`** — fixed-window policies from `RateLimitingOptions`. - **`MapHeroHealthEndpoints`** — anonymous `/health/live` (process only) and `/health/ready` (all checks, 503 on failure), always mapped. ## How modules consume Web A module's `IModule.ConfigureServices` adds its DI registrations; `MapEndpoints` mounts its routes: ```csharp [assembly: FshModule(typeof(CatalogModule), 600)] public sealed class CatalogModule : IModule { public void ConfigureServices(IHostApplicationBuilder builder) { PermissionConstants.Register(CatalogPermissions.All); builder.Services.AddHeroDbContext(); // ... more registrations } public void MapEndpoints(IEndpointRouteBuilder endpoints) { var versionSet = endpoints.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); var group = endpoints .MapGroup("api/v{version:apiVersion}/catalog") .WithTags("Catalog") .WithApiVersionSet(versionSet) .RequireAuthorization(); group.MapCreateBrandEndpoint(); group.MapSearchBrandsEndpoint(); // ... } } ``` The `ModuleLoader` runs `ConfigureServices` for every module in the order declared by each module's assembly-level `[assembly: FshModule(typeof(XModule), order)]` attribute, then later runs `MapEndpoints` for each. ## The pipeline order `UseHeroPlatform` wires the middleware pipeline in a specific order; this is the canonical sequence: 1. `UseExceptionHandler()` — converts exceptions to ProblemDetails 2. `UseResponseCompression()` 3. `UseHeroCors()` — **before** HTTPS redirect (preflight requests can't follow redirects) 4. `UseHttpsRedirection()` 5. `UseHeroSecurityHeaders()` 6. `UseStaticFiles()` (optional, `ServeStaticFiles`) 7. Hangfire dashboard (`/jobs` by default, `HangfireOptions:Route`) 8. `UseRouting()` 9. OpenAPI + Scalar 10. `UseAuthentication()` 11. Module-supplied middleware (via `ConfigureMiddleware` — e.g. Auditing's HTTP capture) 12. `UseHeroRateLimiting()` 13. `UseHeroQuotas()` (optional, `UseQuotas`) 14. `UseAuthorization()` 15. Module endpoint mapping (via `MapEndpoints`) 16. Health endpoints (always), SSE endpoints + SignalR (optional) 17. `CurrentUserMiddleware` (last so authorization is already resolved) ## Configuration The Web block reads many config sections — `CorsOptions`, `OpenApiOptions`, `OriginOptions`, `SecurityHeadersOptions`, `RateLimitingOptions`, plus the optional sub-block sections (`CachingOptions`, `HangfireOptions`, `MailOptions`, `FeatureManagement`, `IdempotencyOptions`, `QuotaOptions`, `HttpResilienceOptions`). The kit's `appsettings.json` template ships a complete starter set. ## How to extend ### Add custom request logic without owning a module `builder.Services.Configure(opts => opts.Filters.Add())` won't work for minimal APIs. Instead, add a Mediator pipeline behavior: ```csharp public sealed class TimingBehavior(ILogger> log) : IPipelineBehavior { public async ValueTask Handle(TRequest msg, MessageHandlerDelegate next, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { return await next(msg, ct).ConfigureAwait(false); } finally { log.LogInformation("{Req} took {Ms}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds); } } } services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(TimingBehavior<,>)); ``` ### Replace the global exception handler Implement your own `IExceptionHandler`, register it as `services.AddExceptionHandler()`. Keep the RFC 9457 ProblemDetails shape — the kit's React clients parse it. ## Gotchas - **CORS before HTTPS redirect.** Preflight `OPTIONS` requests cannot follow an HTTP→HTTPS redirect per the Fetch spec. The kit puts `UseCors` before `UseHttpsRedirection`. Don't reorder. - **`CurrentUserMiddleware` runs last.** It's after authorization, so anything between `UseAuthentication` and `CurrentUserMiddleware` runs with a populated `ClaimsPrincipal` but a null `ICurrentUser`. If you write middleware that needs `ICurrentUser`, place it after this one — or read claims directly. - **`AddHeroPlatform` defaults are conservative.** Caching, jobs, mailing, feature flags, SSE, realtime, quotas are all **off** unless explicitly enabled. `EnableCors`, `EnableOpenApi`, `EnableOpenTelemetry`, and `EnableIdempotency` default to on (CORS and OpenAPI are additionally gated by configuration — no `CorsOptions` origins means no CORS middleware; `OpenApiOptions:Enabled` can switch docs off per environment). - **The `ModuleLoader` reads assembly attributes once at startup.** Adding a module means adding the project, adding the marker assembly to the host's `moduleAssemblies` array, **and** adding both marker types to `AddMediator(o => o.Assemblies = [...])` — in `Program.cs` *and* `DbMigrator/Program.cs` (four places total). Forgetting the Mediator step is the silent failure mode: services build, endpoints map, but handlers aren't found at request time. ## Critical files - `src/BuildingBlocks/Web/Extensions.cs` - `src/BuildingBlocks/Web/Modules/ModuleLoader.cs` - `src/BuildingBlocks/Web/Exceptions/GlobalExceptionHandler.cs` - `src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs` - `src/BuildingBlocks/Web/Auth/CurrentUserMiddleware.cs` ## Related - [Architecture: dependency injection](/docs/architecture/) — module load order + Mediator wiring. - [Modules overview](/docs/modules/) — every module composes against this block. - [Cross-cutting concerns](/docs/cross-cutting-concerns/) — feature flags, idempotency, OpenTelemetry. --- # Overview > Release notes and version history for fullstackhero. Source: https://fullstackhero.net/docs/changelog/ Notable changes to the kit, newest first. ## 2026-07-13 - **Dashboard: tenants can now edit their own branding from Settings.** A new **Settings → Branding** tab lets a tenant admin holding `Tenants.UpdateTheme` customise their **light and dark palettes** and **brand asset URLs** (logo, dark-mode logo, favicon) with a live preview — mirroring the operator's existing tenant-branding card, but self-service and with no `tenant:` header, since the theme endpoints are already scoped to the current tenant. The tab renders only for holders of that permission; a direct-URL visit without it hits the API's `403`, surfaced as an error band. Editing is draft-based — a **Reset to defaults** action and per-palette reset are available, and unsaved edits are preserved while you work (a co-admin's concurrent change appears on a manual refresh rather than overwriting your form). - **The `fsh` CLI and `dotnet new` template are now on NuGet as stable `10.0.0`.** The two distribution packages that 10.0.0 had been waiting on have shipped: `FullStackHero.CLI` (install with `dotnet tool install -g FullStackHero.CLI` — no more `--prerelease`) and `FullStackHero.NET.StarterKit` (`dotnet new install FullStackHero.NET.StarterKit`). Because `fsh new` scaffolds *from* that template, the one-command flow is now end-to-end: `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` produces a fully renamed project — unique JWT signing key, generated Docker secrets, `npm install` run, initial commit on `main`. The [Install](/docs/getting-started/install/) and [CLI](/docs/cli/) pages now lead with the CLI as the recommended path; `git clone` and the GitHub template remain available for reading the source or zero-install runs. See the [10.0.0 release](https://github.com/fullstackhero/dotnet-starter-kit/releases/tag/10.0.0). ## 2026-06-12 - **Both apps: every nav surface is now permission-gated.** A tenant user whose role lacked a module's permission could still see and open its nav entry, landing on a guaranteed `403` — e.g. the dashboard Files page rendering a `ForbiddenException` error band for a user without `Files.Upload`. The dashboard sidebar now gates Chat, My Files, Subscription, Invoices, the Catalog pages, and Tickets on the same permission each page's API enforces (joining the already-gated Identity/Audits/Sessions/Trash entries), and the **command palette** — previously a second, fully ungated surface — applies the same gates to its Navigate *and* Create actions. In the admin console, the Webhooks nav entry and routes gained the `Webhooks.View` gate the API has required since the webhook permission hardening, and the Role editor's permission catalog now includes the **Webhooks** group so those permissions can actually be granted. Server-side enforcement is unchanged — this is the UX layer catching up. - **Demo seed: the acme Manager role's catalog permissions now actually work (fix).** The seeded claims used permission names that don't exist (`Permissions.Brands.View` instead of the real `Permissions.Catalog.Brands.View`, and likewise for Categories/Products), so the "full catalog" Manager demo user silently had no catalog access at all. The seeder now references the module permission constants directly, so the names can't drift again. Existing databases: re-run the migrator's `seed-demo` verb to add the corrected claims (idempotent), and have demo users sign in again to pick them up. ## 2026-06-11 - **Identity: roles granted through groups now confer their permissions (fix).** A user group can carry role assignments, and the issued JWT already listed those group-derived roles as role claims — but the server-side permission resolver only counted roles assigned to the user *directly*. So a user whose only role came via a group failed every permission-gated endpoint with `403` even though their token showed the role, and their effective-permission list came back empty. The resolver now unions direct and group-derived roles, matching what token issuance and the permission-cache invalidation (which already fired on every group change) always assumed. No action required — the fix takes effect on each user's next permission-cache load. ## 2026-06-09 - **Dashboard: revoking an impersonation grant now ends the session gracefully instead of stranding the impersonated view.** When an operator revoked (or let expire) an in-progress impersonation from the admin console, the dashboard kept the now-dead impersonation token installed: every request `401`'d, but the page just rendered a stuck red error band under a half-loaded surface while the "End impersonation" banner lingered — a confusing, broken-looking state. The dashboard now detects this case (a `401` while the active token carries the `act_sub` impersonation claim — a durable signal that works even though production keeps the `401` body opaque) and routes to a calm full-screen **Impersonation ended** page that explains the access was revoked or expired and offers a single **Back to sign in** action, which clears the dead token. Mirrors the existing deactivated-tenant terminal page. - **Dashboard: the Recycle bin only shows the sections you can restore.** The Trash page's five tabs (Products, Brands, Categories, Tickets, Files) were hard-coded with no permission check, so a user with rights to restore some resources but not others could click a tab and hit a `403` error band — e.g. a tenant member without Files access opening the Files tab. Each tab is now gated on the permission its endpoint enforces (the resource's `Restore`, or `Files.ViewTrash`), tabs the user can't reach are hidden, and the active tab falls back to the first visible one. The **Trash** sidebar entry itself hides when the user can reach none of them, and a direct visit shows a "no recycle bins available" state instead of a wall of `403`s. The server still enforces every permission — this is the UX layer catching up to the existing nav-gating pattern. - **Realtime: the dashboard's live feed connects instantly instead of sitting on "Connecting" for up to 15s.** The SSE stream handler set its response headers and then waited for the first event or heartbeat before writing anything; Kestrel buffers response headers until the first body write, and the browser's `fetch()` only resolves once those headers arrive — so on an idle stream the System-status card showed "Connecting" for up to the 15s heartbeat interval on every connect and reconnect. The handler now flushes an initial no-op SSE comment immediately, so the client flips to "Connected" at once. - **Template package: ships only tracked source — no more 583 MB package or leaked Terraform state (fix).** The `dotnet new fsh` template was packed by globbing the whole working tree (`..\**\*` with a denylist), which swept in any local artifact the denylist happened to miss. The result was a **583 MB** NuGet package that bundled `deploy/**/.terraform` provider binaries, **`*.tfstate` files (your infrastructure state — a secret leak)**, tens of MB of `audit-dlq` runtime dumps, and a stray local `release-nupkgs` output dir. Packing is now driven by a **git-tracked allowlist** — an MSBuild target enumerates `git ls-files` and ships only committed files — so nothing local or gitignored can ever leak into the package again. The package drops to **2.9 MB / 2057 files**, the `NU5123` long-path warnings are gone, and the scaffolded output is unchanged. This affects only the published template package, not what you get from a freshly scaffolded project. ## 2026-06-06 - **Realtime: a client disconnecting mid-connect no longer logs a spurious error.** When a SignalR connection dropped while `AppHub.OnConnectedAsync` was still wiring it up — a fast reconnect, a page navigation, or ordinary negotiate/connect churn — the aborting connection token cancelled the in-flight channel lookup, and the resulting `OperationCanceledException` surfaced as an `Error when dispatching 'OnConnectedAsync' on hub` log line with a full EF/Npgsql stack trace. Nothing was actually wrong: there was simply no connection left to set up. The hub now swallows cancellation caused by the connection aborting, so these benign disconnects stay out of the error log. Genuine faults still propagate and log as before. ## 2026-06-04 - **.NET Aspire updated to 13.4.0** — the Hosting packages (`Aspire.Hosting.JavaScript` / `PostgreSQL` / `Redis`) and the AppHost SDK move 13.3.5 → 13.4.0. `StackExchange.Redis` is bumped 2.11.0 → 2.13.17 because `Aspire.Hosting.Redis` 13.4.0 requires ≥ 2.13.1 (a `NU1109` downgrade otherwise). Builds clean with warnings-as-errors; the full suite (unit + Testcontainers integration, incl. the Valkey-backed tests) stays green. - **Action required for existing local dev: wipe your Postgres data volume.** Aspire 13.4.0's default Postgres container image moves to **major 18**, which changed the on-disk data layout (version-specific subdirectories under `/var/lib/postgresql`). Postgres 18 **refuses to start** on a `*-postgres-data` volume written by an older Postgres, failing with `PostgreSQL data in: /var/lib/postgresql`. This affects local Aspire dev only — production deploy stacks pin their own Postgres and are unaffected. Fix: remove the stale volume and relaunch; Aspire re-runs `apply --seed` + `seed-demo` to rebuild and reseed it. ```bash docker volume rm fsh-starter-postgres-data # use your app's prefix; e.g. acme-store-postgres-data dotnet run --project src/Host/FSH.Starter.AppHost ``` ## 2026-06-01 Post-10.0.0 pre-release hardening from a module-by-module audit (correctness, security, completeness, test coverage) across the backend. Every finding was adversarially verified before fixing; the suite stays green (warnings-as-errors, Testcontainers integration tests). - **Chat: restoring an archived channel no longer loses its members (fix).** Archiving a channel called `db.Remove(channel)`, which cascaded the delete onto the `ChannelMember` rows; because the soft-delete interceptor only rescues *owned* references, the members were hard-deleted and a later **restore brought back an empty channel** — the creator then got `404` trying to post. Archiving is now an explicit domain state change that flips the soft-delete flag and leaves membership intact, so a restore is lossless. - **Tickets: the lifecycle and permission set are now complete.** The `Closed` state was unreachable (no way to get there) and the `Tickets.Update` / `Tickets.Delete` permissions were registered with **no endpoints behind them** — so an admin could grant rights that did nothing, and the existing trash/restore had no way to actually trash a ticket. This adds first-class **Close** (`POST /api/v1/tickets/{id}/close`, Resolved → Closed), **Update** (`PUT /api/v1/tickets/{id}` — edit title/description/priority; frozen once Closed), and **Delete** (`DELETE /api/v1/tickets/{id}` — soft-delete; comments survive and return on restore), each with a validator, a permission gate, and a new `Tickets.Close` permission. `GET /api/v1/tickets/{id}/comments` now returns `404` for a non-existent ticket instead of a misleading empty list. See [Tickets](/docs/modules/tickets/). - **Webhooks: signing secrets are encrypted at rest, and the endpoints are permission-gated (security fixes).** The HMAC signing secret was stored as plaintext in a field named `SecretHash` — a database breach would have exposed every tenant's secret. The secret is the HMAC key (it must stay recoverable, so hashing isn't an option), so it is now **encrypted with ASP.NET Data Protection** on create and decrypted only at sign time. Separately, the endpoints were authentication-only — any signed-in user could manage every webhook in their tenant; they now require the new `Webhooks.View` / `Create` / `Delete` / `Test` permissions. **After upgrading, grant these to the roles that manage webhooks or those users will get `403`.** See [Webhooks](/docs/modules/webhooks/). - **Multitenancy & correctness.** The `GET tenant provisioning status` and `retry provisioning` endpoints now accept and forward a `CancellationToken` (graceful shutdown); the Notifications mention handler fails loud on a tenant-context mismatch rather than risking a cross-tenant write; webhook list endpoints validate pagination (a `pageSize=0` previously surfaced as a `500`, now a clean `400`); and the Billing monthly-invoice job takes its clock from `TimeProvider` for deterministic tests. - **Known follow-up.** Billing publishes its `InvoiceIssued` event directly on the in-memory bus rather than through the transactional outbox (Files does the same, with no consumer yet). Closing this needs a small `Eventing` building-block change to support more than one outbox-backed module; tracked for a follow-up release. The in-memory path is correct today. ## 10.0.0 — 2026-05-28 The first stable **10.0.0** release. fullstackhero is now a complete **.NET 10 modular monolith plus two React 19 apps** — and you get the **full source**, no black-box runtime packages. Available today via `git clone` or the GitHub template; the `fsh` CLI and the `dotnet new fsh` template publish to NuGet shortly. - **Backend** — .NET 10 / EF Core 10 modular monolith (Vertical Slice + source-generated Mediator CQRS) across **10 modules**: Identity, Multitenancy, Billing, Catalog, Tickets, Chat, Files, Webhooks, Auditing, and Notifications. Multitenant by default (Finbuckle), JWT + ASP.NET Identity, HybridCache on Valkey, Hangfire jobs, presigned S3/MinIO storage, OpenAPI + Scalar, and Serilog + OpenTelemetry. - **Front-ends** — two **React 19 + Vite 7 + TypeScript** apps: an operator console (`admin`) and a tenant app (`dashboard`), with TanStack Query v5, Tailwind v4, and SignalR/SSE real-time. - **One-command local dev** — **.NET Aspire** brings up Postgres + pgAdmin, Valkey + RedisInsight, MinIO, the migrator, demo data, the API, and both front-ends. **Docker Compose** and **AWS/Terraform** cover deployment. - **Tested & enforced** — 1,600+ backend tests (xUnit, Testcontainers, NetArchTest boundaries) and 200+ Playwright E2E tests, with path-scoped backend/frontend CI and warnings-as-errors. - **Polish in this release** — the `fsh` CLI gained a `--version` flag and a corrected (semver-aware) update check; the unimplemented `--db sqlserver` scaffold option was removed (PostgreSQL is the supported provider); AppHost resource names are namespaced per app; and a batch of scaffold/DX fixes landed (see the dated entries below). See the dated entries below for the complete list of changes that shipped into 10.0.0. ## 2026-05-30 - **Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes).** A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller's tenant. Because `BillingDbContext` is intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly — and several didn't. A tenant admin (who holds the basic `Billing.View`/`Billing.Manage` permissions) could read, **issue, pay, or void another tenant's invoices** by id, **reassign or cancel another tenant's subscription** via a body `tenantId`, **read or fabricate another tenant's usage**, or trigger **platform-wide invoice generation**. The by-id read/PDF paths and every mutation path now gate on the root operator — the operator acts cross-tenant, every other tenant is pinned to its own — and `POST /api/v1/billing/invoices/generate` is now operator-only. Separately, a role-permission filter only stripped a `Permissions.Root.` name prefix that matches **no** real operator permission, so a non-root tenant admin with `Roles.Update` could grant their own role the operator-only `Tenants.*` / `Platform.*` permissions and escalate to managing every tenant; the filter now keys off the registered `IsRoot` flag. Existing isolation tests missed all of this because they always authenticate with a *matching* tenant header — they never exercised one tenant's token acting on another's data. New integration tests cover each scenario. (The `tenant`-header-vs-JWT-claim path was investigated and is **not** affected — Finbuckle's claim strategy binds the resolved tenant to the JWT claim for non-root callers.) - **The API now serializes enums as their string names (contract change).** Every enum in an API response is emitted as its name (`"Active"`, `"Paid"`, `"Security"`) instead of a numeric value, via a global `JsonStringEnumConverter`; reading still accepts either form, so request bodies are unaffected. `[Flags]` enums (`AuditTag`, `BodyCapture`) stay numeric. Both bundled React apps already mirror this as string-union types — but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription's `status` serialized as `0` and surfaced as a stray "0" in the dashboard. - **Billing correctness.** The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice *purpose*), so overage went unbilled — it's now scoped to the usage invoice. A same-plan renewal advanced the tenant's validity but left the subscription's end date unchanged, so the dashboard's subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant "provisioned" with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant's validity can no longer be adjusted. - **Front-end polish.** The **admin** console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with `403` on submit) and shows a real error state on the invoice page instead of a stuck "Loading…". The **dashboard** landing page's validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list. ## 2026-05-28 - **Tenant billing is now complete end-to-end — expiry/renewal emails, PDF invoices, and a tenant-facing billing view.** Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (`tenant-expiry-scan`, 02:00 UTC) classifies every active tenant as *nearing expiry*, *in grace*, or *expired* and emails the tenant admin — deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as **PDF** (`GET /api/v1/billing/invoices/{id}/pdf`, QuestPDF behind a swappable `IInvoicePdfRenderer`); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The **dashboard** gains a `/subscription` page (plan, validity, usage, recent invoices), a global expiry/grace **warning banner**, and invoice detail with PDF download; the **admin** console gets a PDF button, client-side plan-form validation, and an **Adjust validity** operator override (`POST /tenants/{id}/adjust-validity`) that sets a tenant's expiry directly with no invoice — for comps and corrections. New config key `Billing:ExpiryNotificationLeadDays` (default 7). *Note: QuestPDF's Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license — the dependency is isolated behind `IInvoicePdfRenderer` if you prefer to swap it.* - **Background-published lifecycle events no longer crash the webhook fan-out (fix).** The generic webhook fan-out handles every integration event and reads a tenant-filtered context that captures the ambient tenant *at construction* — so events published from a background job (no HTTP request) hit a null tenant and threw. Background publishers (the new expiry scan) now install the tenant context before publishing, so the webhook fan-out and email handlers run correctly. The renewal stacking math also now uses the injected clock (was `DateTime.UtcNow`), and a `X-Subscription-Grace` response header reports the days left while a tenant is in its grace window. - **Chat delivers messages live to recipients who weren't in the conversation when they connected** — chat broadcasts each message to the channel's SignalR group, but a connection only joined the groups for channels it already belonged to *at connect time* (`AppHub.OnConnectedAsync`). So a brand-new DM, or being added to a channel mid-session, never received live messages — the recipient saw nothing until they reloaded the page. The hub now exposes a membership-checked `JoinChannel` method that the dashboard invokes when a conversation is opened and again on reconnect, so a live socket joins the group on demand. Creating a DM also notifies the other participants (via their `user:{id}` group), so the new conversation appears in their channel rail without a refresh. - **Deactivated tenants are now actually blocked (security fix)** — deactivating a tenant only flipped an `IsActive` flag in the tenant store; nothing in the auth or request pipeline enforced it, so a deactivated tenant's users could still log in and use the API. Tenant resolution now rejects requests for a deactivated tenant with `403 Forbidden` — covering login, token refresh, and every API/realtime request — via a post-authentication guard. Operators (the root tenant) are exempt so they can still manage and reactivate tenants. Deactivation also now invalidates the tenant's distributed-cache entry, so the change takes effect on the very next request instead of waiting out the 60-minute cache. ## 2026-05-27 - **Dependencies updated to latest for the v10 release** — .NET **Aspire 13.3.5** (Hosting packages + AppHost SDK), Finbuckle.MultiTenant 10.1.0, MailKit/MimeKit 4.17.0, AWSSDK.S3 4.0.23.4, Scalar.AspNetCore 2.14.14, and SonarAnalyzer 10.27. Builds clean with warnings-as-errors and the full test suite (unit + Testcontainers integration) stays green. - **Template packaging fixes — scaffolded Dockerfiles and dev-machine packing** — `dotnet new fsh` / `fsh new` packed extensionless files (every `Dockerfile`) to a doubled nested path, so scaffolded projects got a `Dockerfile` *directory* instead of a file and `deploy/docker` (`docker compose up`) was broken. Also made the IDE-cache excludes (`.vs`/`.idea`/`.vscode`) recursive so `dotnet pack` no longer fails (or bundles IDE junk) when packing the template on a developer machine. Scaffolded output now builds and self-hosts cleanly. - **Scaffolded apps log in out of the box, get isolated data volumes, and start on `main`** — three `fsh new` / Aspire DX fixes: the AppHost migrator now runs `apply --seed`, so the root admin (`admin@root.com`) is seeded automatically — previously a freshly-run app came up with an empty user table and **nobody could log in**; each app's Docker volumes are namespaced by app name (e.g. `myapp-postgres-data`) instead of sharing a literal `postgres-data`, so two FSH-based apps on one machine no longer clobber each other's database; and `fsh new` initializes git on `main` rather than following the machine's git default (often `master`). - **Demo logins (`acme`/`globex`) work on a fresh Aspire launch** — the dashboard's demo-login panel advertised accounts that were never seeded: the AppHost migrator ran only `apply --seed` (which seeds the root admin), while the `acme`/`globex` demo tenants are created by the dev-only `seed-demo` verb. Aspire now runs `seed-demo` as a dedicated demo-seeder step after migration — so `admin@acme.com` / `Password123!` works the moment the dashboard loads. Also fixes the migrator crashing at startup in Development (its trimmed service graph tripped the DI container's build-time validation) and corrects the verb's environment gate to `DOTNET_ENVIRONMENT` (the migrator is a generic-host console app, not a web host). - **Aspire resource names are namespaced per app** — the AppHost's resource/container names (API, migrator, demo-seeder, admin, dashboard) now derive from the app's namespace, like the Docker volume names already did. A scaffolded `Acme.Store` shows `acme-store-api` etc. instead of the kit's literal `fsh-*`, so two FSH-based apps on one machine don't collide. (This repo resolves to `fsh-starter-*`; the `postgres`/`redis`/`minio` infra and the `fsh-db` database keep stable names.) - **Stale sessions resolve cleanly instead of erroring** — both React apps (admin + dashboard) treated an expired token left in `localStorage` as signed-in, firing protected requests that 401'd in a loop (`SecurityTokenExpiredException`). On boot they now attempt one silent token refresh: success restores the session, failure routes to `/login`. Long-lived sessions still refresh transparently mid-use. - **CI split into path-scoped backend + frontend pipelines** — the single `ci.yml` is replaced by `backend.yml` (runs only on `src/**` changes) and `frontend.yml` (runs only on `clients/**`), so a client-only change never builds or tests the API, and vice versa. The SDK is pinned to the .NET 10 **GA** release via a root `global.json` (no more preview channel). Unit and integration tests each run **once**, and the coverage gate merges their results instead of re-running the whole solution. The React apps get real CI for the first time — ESLint, `tsc`/Vite build, and the Playwright E2E suites (admin + dashboard) on Node 22. Branch protection requires the always-resolving `Backend CI` / `Frontend CI` gate jobs. See [CI/CD](/docs/deployment/ci-cd/). - **Consolidated to a single `main` branch** — the repo now uses one long-lived default branch, `main`; the `develop` branch is retired. Branch from and target `main`; stable releases are cut from `v*` tags. See [Contributing](/docs/contributing/). - **Removed the redundant root `docker-compose.yml`** — local development is covered by .NET Aspire and production by `deploy/docker/`, so the overlapping root compose file (added 2026-05-24) was dropped. - **Missing required request parameters now return `400`, not `500`** — calling a tenant-scoped endpoint without the `tenant` header (and any other endpoint missing a required header/route/query parameter, or sent with an unreadable/oversized body) raised an ASP.NET `BadHttpRequestException` that the global exception handler rendered as a generic `500 Internal Server Error`. The handler now honours the framework's own status code, so these surface as a proper `400 Bad Request` (or `413`, etc.) with a `ProblemDetails` body. Fixes [#1245](https://github.com/fullstackhero/dotnet-starter-kit/issues/1245). ## 2026-05-24 - **Cache/store engine switched from Redis to Valkey 8** — the BSD-licensed, Linux Foundation fork of Redis. It's a drop-in over the Redis protocol (RESP): the `StackExchange.Redis` client and every `CachingOptions:Redis` config key are unchanged. Applies to .NET Aspire, both Docker Compose files, and the integration-test container. - **RedisInsight cache browser** is now auto-wired in Aspire, connected to the Valkey instance so you can inspect cache keys, TTLs, and the SignalR backplane in local dev with no manual configuration. - **Docker Compose hardening** — the production `deploy/docker` stack now provisions the MinIO bucket before the API starts (fixes a first-upload `NoSuchBucket`); the dev root `docker-compose.yml` now runs the DB migrator (`apply --seed`) so the API never boots against an empty schema. --- # Overview > The fsh CLI — scaffold projects, check your environment, and stay current. Source: https://fullstackhero.net/docs/cli/ A NuGet-distributed dotnet tool ([Spectre.Console](https://spectreconsole.net/), source at `src/Tools/CLI`) for scaffolding fullstackhero projects and verifying your environment. Four commands: `new`, `doctor`, `info`, `update`. ```bash dotnet tool install -g FullStackHero.CLI ``` `FullStackHero.CLI` is published as stable **10.0.0**. `fsh new` scaffolds from the `FullStackHero.NET.StarterKit` template package (also 10.0.0), so the install-then-scaffold flow works end-to-end. You can also run the CLI straight from a clone of the repo: ```bash dotnet run --project src/Tools/CLI -- doctor ``` ## fsh new Scaffolds a fresh, fully renamed project from the latest template. Run bare for the interactive wizard (project name, include Aspire?, include the React apps?), or pass everything as flags. ```bash fsh new MyApp fsh new MyApp --no-aspire --no-frontend fsh new MyApp --non-interactive -o ./somewhere-else ``` | Flag | What it does | |---|---| | `[name]` | Project name — used for the solution, namespaces, and folders | | `-o, --output` | Output directory (defaults to `./`) | | `--no-aspire` | Exclude the .NET Aspire AppHost project | | `--no-frontend` | Exclude the React admin + dashboard apps | | `--skip-install` | Skip `npm install` for the React apps after scaffolding | | `--non-interactive` | Skip prompts and use defaults (name becomes required) | | `--git` | Initialize a git repository (on by default) | | `--dry-run` | Show what would be created without creating anything | Beyond renaming, `new` does the setup you'd otherwise do by hand: installs the template if it's missing, replaces the shared dev JWT signing key with a unique per-project one, generates `deploy/docker/.env` with strong random secrets so `docker compose up` works immediately, runs `npm install` in both React apps, and makes the initial commit on a `main` branch. ## fsh doctor Checks your environment and prints a pass/warn/fail table: - **.NET SDK** — present and version 10+ - **Git** — installed - **Docker** — installed *and* running - **FSH template** — installed (warns with the install command if not) - **Ports** — `5030`, `7030` (API) and `15888` (Aspire dashboard) free Exits non-zero if a required tool is missing, so it's scriptable. ## fsh info Shows the CLI version, the latest version on NuGet (and whether an update is available), the installed and latest template versions, your .NET SDK version, and links to the docs and release notes. ## fsh update Updates both halves in one go: `dotnet tool update -g FullStackHero.CLI` for the CLI, then reinstalls the latest `FullStackHero.NET.StarterKit` template. --- # Overview > Honest, fact-checked comparisons of fullstackhero against the most common .NET starter kits and frameworks — ABP, Clean Architecture templates, and BlazorPlate. Source: https://fullstackhero.net/docs/compare/ fullstackhero is one of several ways to start a production .NET project. The three pages below compare it head-to-head with the most common alternatives so you can pick deliberately. Each page is maintained by us, so it's our perspective. The intent is to be **specific about where each option diverges**, not to win an argument. Where any of these pages drifts out of date or treats an alternative unfairly, [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) and we'll fix it. ## Available comparisons - **[fullstackhero vs ABP Framework](/docs/compare/fsh-vs-abp/)** — the biggest direct competitor. ABP is a complete opinionated framework with a paid commercial tier; fullstackhero is copy-and-own MIT source. - **[fullstackhero vs Clean Architecture templates](/docs/compare/fsh-vs-clean-architecture/)** — Jason Taylor and Ardalis templates ship an *architecture*; fullstackhero ships ten production *modules* on top of one. - **[fullstackhero vs BlazorPlate](/docs/compare/fsh-vs-blazorplate/)** — free open-source + React versus paid closed-source + Blazor. ## Quick decision tree - **You want named vendor support + admin UI generator + heavyweight framework** → ABP Framework (its commercial tier). - **You want to learn or assemble Clean Architecture from a clean slate** → Jason Taylor's or Ardalis's Clean Architecture template. - **You want Blazor + paid support + one-time licence + i18n** → BlazorPlate. - **You want free + open source + ten production modules + React + zero lock-in** → fullstackhero. ## Where to go next - [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in a minute. - [Architecture overview](/docs/architecture/) — modular monolith + Vertical Slice Architecture. - [Modules](/docs/modules/) — the ten modules that ship in v10. --- # fullstackhero vs ABP Framework > A fair comparison of fullstackhero and ABP Framework for building production .NET 10 applications. License, architecture, learning curve, and when to pick which. Source: https://fullstackhero.net/docs/compare/fsh-vs-abp/ ABP Framework and fullstackhero both target the same problem — getting a production-grade .NET application off the ground without spending months on the infrastructure plumbing every multi-tenant app needs. They pick very different shapes to solve it. This page is an honest comparison so you can pick the right one for your team. > **TL;DR.** ABP is a complete opinionated framework — its own DI conventions, its own CLI, its own module system, a paid commercial tier with admin UI and support contracts. fullstackhero is a copy-and-own starter: standard ASP.NET Core, plain DI, free under MIT, with ten production modules pre-wired and the source code in your repo from day one. If you want named vendor support and an admin app generator, pick ABP. If you want to own the code and use the .NET your team already knows, pick fullstackhero. ## Side-by-side | | **fullstackhero** | **ABP Framework** | |---|---|---| | License | MIT — every line | LGPL (free) + ABP Commercial (paid) | | Cost | Free | Free tier; ABP Commercial from \$1,490+/yr (per team / app) | | Distribution | Clone the repo, install `FullStackHero.CLI`, or `dotnet new fsh` | NuGet packages (`Volo.Abp.*`) + ABP Studio / ABP Suite | | Architecture | Modular monolith with Vertical Slice Architecture inside each module | Layered DDD module system (Domain / Application / HttpApi / Web / EntityFrameworkCore per module) | | Code ownership | You own the entire codebase from day one | You consume Volo NuGet packages; the framework lives upstream | | DI | Standard `Microsoft.Extensions.DependencyInjection` | Custom `IServiceCollection` extensions and module-level conventions | | Mediator | Mediator 3.0.2 source generator (no runtime reflection) | ABP's own service registration + dynamic proxying | | Validation | FluentValidation 12.1 | DataAnnotations + ABP's `IValidatableObject` extensions | | Multitenancy | Finbuckle.MultiTenant 10.1 — claim / header / query strategies | First-party module: `Volo.Abp.MultiTenancy` | | ORM | EF Core 10 (PostgreSQL via Npgsql by default; SQL Server provider available) | EF Core with abstraction layer + LINQ via repository pattern | | Background jobs | Hangfire 1.8 | ABP's `IBackgroundJobManager` (multiple providers) | | Realtime | SignalR + Server-Sent Events | SignalR via ABP modules | | Observability | Serilog 4 + OpenTelemetry 1.15 (OTLP exporter), pre-wired | OpenTelemetry support; configuration via ABP modules | | Frontend included | React + Vite admin and dashboard, in the repo | MVC + Razor / Blazor / Angular options — Lepton theme is paid in ABP Commercial | | CLI scaffolding | `fsh new ` | `abp new ` (Studio offers a GUI) | | Stars (May 2026) | 6.4k★ | 14.2k★ | | Maintainer | Mukesh Murugan + community | Volosoft (commercial company) | ## When to choose ABP Framework You should pick ABP — happily — if any of the following are true: - **You need vendor support contracts.** ABP Commercial includes a paid support tier with SLAs. fullstackhero is community-maintained; there is no enterprise hotline. - **You want a UI admin app generator.** ABP Studio and ABP Suite can scaffold CRUD UIs against your entities. fullstackhero ships an admin console but not a code generator for new screens. - **You'd rather consume framework upgrades as NuGet bumps.** ABP's framework lives in your package references. Major framework improvements arrive as `dotnet add package` upgrades. fullstackhero is copy-and-own — improvements arrive as upstream commits you cherry-pick. - **Your team is already fluent in ABP conventions.** ABP's module system, DI extensions, and repository pattern are a real learning investment. If you've made it, throwing it away is expensive. ## When to choose fullstackhero Pick fullstackhero — also happily — if any of these match: - **You want the code in your repo, not a framework above it.** Every file is yours. Every line is editable. No DLL surprises during a major .NET upgrade; no licence changes to react to. - **You'd rather use the .NET your team already knows.** Plain ASP.NET Core minimal APIs, plain `IServiceCollection`, Mediator + FluentValidation. Anyone who knows current-day .NET can be productive in fullstackhero within an afternoon. - **You need the entire stack free under MIT.** ABP's framework is LGPL, but the commercial admin UI, theme, code generators, and support are paid. fullstackhero is end-to-end free including the React admin console and the dashboard. - **You want Vertical Slice Architecture inside a modular monolith.** ABP defaults to layered DDD per module (Domain / Application / HttpApi / …). fullstackhero defaults to one feature, one folder, one PR. Both are valid; pick the one that matches how you ship. - **You want zero vendor lock-in.** No upstream framework to track. No commercial roadmap that might pivot away from your needs. Fork the kit, freeze it, and never look back if that's what your project requires. ## What the two share Worth saying out loud: ABP and fullstackhero agree on most of what matters. - Both are production-ready, multi-tenant by default, with identity, audit, background jobs, caching, and OpenAPI built in. - Both ship as full reference implementations with hundreds of integration tests. - Both can scale to large teams and large codebases — ABP through its module catalogue, fullstackhero through extract-a-module-to-a-service when you need to. The choice is mostly about **how much framework you want above your code**, and **whether you need paid support**. ## Migration notes If you're moving from ABP to fullstackhero (or thinking about it): - **Modules map.** ABP's `Volo.Abp.Identity` lines up with fullstackhero's `Modules.Identity`. `Volo.Abp.MultiTenancy` lines up with `Modules.Multitenancy`. `Volo.Abp.AuditLogging` lines up with `Modules.Auditing`. - **Repositories become DbContexts.** Where ABP uses `IRepository` and the Specification pattern, fullstackhero uses EF Core `DbSet` directly inside the slice handler. Less indirection; same SQL. - **App services become Mediator handlers.** What ABP calls an "application service" is split into a command/query + handler in fullstackhero. The contract lives in `Modules.X.Contracts`; the handler lives in `Modules.X`. - **No upstream NuGet to keep current.** Once you've cloned fullstackhero you own the code. There's no `dotnet add package Volo.Abp.X` upgrade cycle. ## Honest disclaimers This page is maintained by fullstackhero, so treat it as our perspective. ABP is a serious project with a mature community and a long track record — for many teams it's the right answer. The intent here is not to win an argument, just to be specific about where the two diverge so you can pick deliberately. If anything on this page is out of date or unfair to ABP, please [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) — we'll fix it. ## Where to go next - [Architecture overview](/docs/architecture/) — how fullstackhero is put together. - [Modules](/docs/modules/) — the ten modules that ship in v10. - [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in under a minute. - [ABP Framework documentation](https://docs.abp.io/) — the canonical reference for the alternative. --- # fullstackhero vs BlazorPlate > How fullstackhero compares to BlazorPlate — the difference between a free, open-source .NET 10 starter kit and a paid closed-source SaaS template. Source: https://fullstackhero.net/docs/compare/fsh-vs-blazorplate/ BlazorPlate is a paid commercial multi-tenant SaaS starter for .NET, sold as a one-time license. fullstackhero is a free, MIT-licensed open-source starter that targets the same job: spinning up a multi-tenant .NET SaaS without writing months of plumbing. This page is an honest comparison so you can make the call deliberately. > **TL;DR.** BlazorPlate is a polished commercial template for Blazor-first teams who want a finished product with paid support behind it. fullstackhero is free and open-source, ships two React frontends instead of Blazor, and gives you the full source under MIT. If you need Blazor + paid support + a single one-time payment, BlazorPlate is built for that. If you want zero cost, open source, and React frontends, fullstackhero fits. ## Side-by-side | | **fullstackhero** | **BlazorPlate** | |---|---|---| | License | MIT — open source | Commercial — closed source | | Cost | Free | Standard \$499 (one developer) · Enterprise \$999 (team) — one-time payment | | Source access | Full source on GitHub | Source delivered after purchase; redistribution restricted | | Distribution | `git clone` / `dotnet new fsh` / `fsh new` | Download bundle after purchase | | Architecture | Modular monolith + Vertical Slice Architecture | Clean Architecture | | Multitenancy | Finbuckle.MultiTenant 10 — claim / header / query strategies, EF Core global query filter | Built-in — dedicated, shared, single-tenant deployment modes | | Identity | ASP.NET Identity + JWT + refresh + permissions + impersonation | ASP.NET Identity + JWT + permissions | | Frontend | React 19 + Vite admin console + tenant dashboard (both in the repo) | Blazor Server + Blazor WebAssembly | | i18n | Not built-in | 20+ languages, RTL support | | Background jobs | Hangfire 1.8 | Hangfire | | Observability | Serilog 4 + OpenTelemetry 1.15 (OTLP) | Serilog | | Realtime | SignalR (Valkey backplane) + Server-Sent Events | SignalR | | File storage | Tenant-scoped MinIO / S3 abstraction | File storage primitives | | Email | MailKit / SendGrid | Email service | | Webhooks | Tenant-scoped subscriptions + HMAC-signed payloads | Not included | | API browser | Scalar (OpenAPI 3.1) | Swagger | | Live demo | Not yet | Yes, hosted | | Support | Community (GitHub issues + discussions) | Paid email support | | Updates | Pull from upstream, your call | Free for 12 months after purchase | | Maintainer | Mukesh Murugan + community | Commercial product company | ## When BlazorPlate is the right call There are real reasons to pick BlazorPlate: - **You're a Blazor team.** BlazorPlate ships a complete Blazor Server + WebAssembly experience tuned to the product. fullstackhero ships React frontends — if you'd rather not maintain React, BlazorPlate is the closer fit. - **You need built-in internationalisation.** BlazorPlate ships with 20+ languages and right-to-left layout support. fullstackhero is English-only out of the box; you'd add i18n yourself. - **You want one paid support email address.** A commercial product gives you a single inbox to escalate to. fullstackhero is community-maintained; questions go through GitHub Discussions and issues, no SLA. - **You're comfortable with closed source for the speed.** Some teams trade source-access for "it works, ship it." If that's you, BlazorPlate is engineered for that trade. - **You prefer a one-time licence over a subscription.** BlazorPlate is a flat fee. No recurring billing. Some teams strongly prefer that model. ## When fullstackhero is the right call Pick fullstackhero if any of these apply: - **You want zero cost, end-to-end.** Hobby project, side project, venture-funded startup — the license is the same: free under MIT. No \$499 cheque, no licence renewal, no per-seat math. - **You need the source open.** Open source on day one, on GitHub, with the full commit history. Audit it. Fork it. Submit pull requests. Send to a security team. - **You want React, not Blazor.** Two production React + Vite apps ship in the repo (admin console + tenant dashboard). If your team is React-native, you're not paying a Blazor tax. - **You want vertical-slice features, not layered CRUD.** fullstackhero defaults to one feature = one folder. BlazorPlate is layered Clean Architecture — same valid pattern, different shape. - **You want webhook delivery built in.** fullstackhero ships tenant-scoped webhook subscriptions with HMAC-signed payloads (handy for integrating tenants' systems with yours). BlazorPlate doesn't ship a webhooks module. - **You want OpenTelemetry traces / metrics / logs out of the box.** fullstackhero wires the full OTLP exporter for traces, metrics, and logs. BlazorPlate ships Serilog only. ## A fair shared list Worth saying: both kits are mature, production-grade work. Both: - Are multi-tenant by default. - Cover identity, jobs, caching, file storage, email, OpenAPI, and audit. - Have an admin UI in their respective frontend tech. - Are designed for B2B SaaS deployments. The biggest split is **open source + free + React** (fullstackhero) versus **closed source + paid + Blazor** (BlazorPlate). The other deltas are detail. ## What about a hybrid? If you've already bought BlazorPlate and want the things fullstackhero adds (Webhooks, OpenTelemetry, additional sample modules), you can lift specific modules across — each fullstackhero module is its own project and only depends on its sibling `Contracts` assembly + the BuildingBlocks. The reverse — porting BlazorPlate features into fullstackhero — is restricted by BlazorPlate's licence, so check before doing it. ## Honest disclaimers This page is maintained by fullstackhero, so it's our perspective. BlazorPlate is a real product with real customers — if the trade-offs above point you at it, you'll be in good hands. The point of this page isn't to win; it's to be specific about where the two diverge so you can pick deliberately. If anything here is out of date or unfair to BlazorPlate, please [open an issue](https://github.com/fullstackhero/dotnet-starter-kit/issues) — we'll fix it. ## Where to go next - [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in under a minute. - [Architecture overview](/docs/architecture/) — Vertical Slice inside a modular monolith. - [vs ABP Framework](/docs/compare/fsh-vs-abp/) — the other big comparison. - [BlazorPlate](https://www.blazorplate.net/) — the commercial alternative. --- # fullstackhero vs Clean Architecture templates > How fullstackhero compares to the Jason Taylor and Ardalis Clean Architecture templates — the difference between shipping an architecture and shipping production modules on top of one. Source: https://fullstackhero.net/docs/compare/fsh-vs-clean-architecture/ The Clean Architecture templates by Jason Taylor and Steve Smith (Ardalis) are the two most popular starting points for new .NET projects on GitHub. Together they sit at around 38,000 stars. They're excellent — and they solve a different problem than fullstackhero. > **TL;DR.** Clean Architecture templates ship an *architecture*: the four-project layout, dependency inversion, MediatR plumbing, a sample feature. fullstackhero ships ten production *modules* on top of an architecture: Identity, Multitenancy, Auditing, Files, Chat, Notifications, Webhooks, Billing, Catalog, Tickets — already wired through a modular monolith. Pick a CA template if you want to learn or assemble; pick fullstackhero if you want to start shipping features today. ## What each template actually contains | | **Jason Taylor CA** | **Ardalis CA** | **fullstackhero** | |---|---|---|---| | Project layout | Domain / Application / Infrastructure / Web | Core / Infrastructure / Web | BuildingBlocks / Modules (each with runtime + Contracts) / Host | | Architectural style | Clean Architecture (layered + dependency inversion) | Clean Architecture (layered + dependency inversion) | Modular monolith + Vertical Slice Architecture per module | | Sample feature | One sample (e.g. TodoList) | One sample | Ten production modules + sample features per module | | Identity / Auth | Stub / sample | Stub / sample | JWT bearer + refresh + permissions + impersonation + rate-limited auth flows | | Multitenancy | Not included | Not included | Finbuckle 10 with EF Core global query filter, IGlobalEntity opt-out, tenant-aware cache + jobs + outbox | | Auditing | Not included | Not included | EF SaveChanges interceptor + per-entity before/after + queryable `/audits` | | Background jobs | Not included | Not included | Hangfire 1.8 with tenant context preserved | | Observability | Console logging | Serilog example | Serilog 4 + OpenTelemetry 1.15 (OTLP) pre-wired | | Caching | Not included | Not included | HybridCache (in-memory + Valkey) with tenant-scoped invalidation | | API browser | Swagger | Swagger | Scalar (OpenAPI 3.1) on `/scalar/` | | Frontend | Angular 21 / React 19 (sample) | None (API-only) | React 19 + Vite admin + dashboard, in the repo | | Stars (May 2026) | ~20,100★ | ~18,200★ | ~6,400★ | | License | MIT | MIT | MIT | | Maintainer | Jason Taylor | Steve "Ardalis" Smith | Mukesh Murugan + community | ## What Jason Taylor's template is great at Jason Taylor's [`CleanArchitecture`](https://github.com/jasontaylordev/CleanArchitecture) template is the de-facto reference for layered CA in .NET. It ships: - A polished four-project layout (Domain / Application / Infrastructure / Web). - A complete sample feature — `TodoList` with CRUD endpoints, validators, MediatR handlers. - Frontend SPAs to choose between (Angular 21 or React 19). - Excellent code commentary so you can learn CA conventions by reading. It is intentionally minimal in what it *adds*. There is no identity beyond ASP.NET Identity scaffolding, no multitenancy, no observability beyond console logging, no file storage, no webhooks, no realtime. That is by design — the template's job is to teach you the layout and let you fill in the rest. ## What Ardalis's template is great at Ardalis's [`CleanArchitecture`](https://github.com/ardalis/CleanArchitecture) is the other canonical CA template. It ships: - A simpler three-project layout (Core / Infrastructure / Web) tuned for the common case. - The same dependency-inversion discipline as Jason Taylor's, with NuGet packages (`Ardalis.Specification`, `Ardalis.Result`, `Ardalis.GuardClauses`) Steve maintains alongside. - A long pedagogical lineage — there's a Pluralsight course and an O'Reilly book that walk through it. - An explicit statement in the README: "this template does not include every possible framework or feature." If you want CA the way Steve teaches it, this is the most authentic source. ## What fullstackhero is great at fullstackhero starts from a different premise: most production B2B and SaaS apps are going to need the same ten or so modules anyway (identity, multitenancy, auditing, file storage, chat, notifications, webhooks, billing, catalog, tickets) plus the same dozen infrastructure pieces (caching, jobs, observability, idempotency, feature flags, …). So ship them. That means: - **Day one is feature work.** The unglamorous parts are done. You don't decide where the JWT keys live, how multitenancy plugs into EF Core, how Hangfire respects tenant context, or how to wire OpenTelemetry — they're decided, wired, and tested. - **The architecture is Vertical Slice inside a modular monolith.** Each feature lives in one folder — endpoint, command, handler, validator, tests. No three-layer round-trip to add a button. - **Two React frontends ship in the repo** (admin console + tenant dashboard), so you don't reinvent the auth-flow / permission-UI / audit-viewer wheel. ## Honest trade-offs There are real reasons to pick a CA template over fullstackhero: - **You want to *learn* Clean Architecture.** Jason Taylor's template is the better classroom. fullstackhero assumes you know what handlers, repositories, and the dependency rule are. - **You're building a small focused service** that doesn't need ten modules. fullstackhero is over-engineered for a single-purpose internal tool. Use a CA template (or `dotnet new webapi`) instead. - **Your team is already deeply invested in CA.** If three years of code is structured around the four-project layout and `IApplicationDbContext`, switching to Vertical Slice is a real conversion. Refactor in place rather than swap. - **You want a layered service / domain split** that lets non-feature-developers work on infrastructure without touching features. CA's layer split makes that explicit; VSA blurs it. ## Can I use Clean Architecture inside fullstackhero? Yes, with caveats. Each fullstackhero module is its own project — nothing stops you from organising the *inside* of a module as Domain / Application / Infrastructure layers. The kit's outer shape (modular monolith with `*.Contracts` boundaries) stays the same; the inner shape becomes CA. You'd be paddling against the grain, though. The defaults — endpoint + handler + validator in one folder — are tuned for Vertical Slice. If you want strict CA discipline, a CA template is a less ambiguous starting point. ## Where to go next - [Architecture overview](/docs/architecture/) — Vertical Slice Architecture inside a modular monolith. - [Quick Start](/docs/getting-started/quick-start/) — try fullstackhero in a minute. - [vs ABP Framework](/docs/compare/fsh-vs-abp/) — the other big alternative. - [Jason Taylor's `CleanArchitecture`](https://github.com/jasontaylordev/CleanArchitecture) — if a CA template is the right call. - [Ardalis's `CleanArchitecture`](https://github.com/ardalis/CleanArchitecture) — the other canonical CA template. --- # Overview > Help shape fullstackhero — branching model, issues, PRs, and conventions. Source: https://fullstackhero.net/docs/contributing/ # Contributing See the repo's [CONTRIBUTING.md](https://github.com/fullstackhero/dotnet-starter-kit/blob/main/CONTRIBUTING.md) for the latest guidelines. ## Branching model The repo uses a **single long-lived branch: `main`** (the default). There is no `develop` branch. - **Branch from and target `main`.** Open your feature/fix branch off the latest `main` and point your PR back at `main`. - **Releases are tags, not a branch.** Stable releases are cut from `v*` tags (e.g. `v10.0.0`); "latest stable" is the newest tag/GitHub Release, and `main` stays releasable. Older majors, if maintained, live on short-lived `release/x.y` branches. - **CI gates the merge.** A PR must pass the `Backend CI` and/or `Frontend CI` checks (whichever your change touches — see [CI/CD](/docs/deployment/ci-cd/)) before it can merge. Rebase onto `main` before requesting review so your PR applies cleanly and CI runs against the current baseline. --- # Overview > Platform features that span every module — caching, jobs, observability, idempotency, feature flags, rate limiting, health, realtime, SSE, HTTP resilience, and error handling. Source: https://fullstackhero.net/docs/cross-cutting-concerns/ Eleven platform features span every module in fullstackhero. They wire once at the host layer, then any module consumes them through the same interfaces and conventions — no per-module bespoke setup. The kit's `FshPlatformOptions` (passed to `AddHeroPlatform`) toggle each optional sub-system. Caching, jobs, mailing, feature flags, SSE, realtime, and quotas are **off by default** — opt in to what you need. CORS, OpenAPI, OpenTelemetry, idempotency, and global exception handling are on by default because the cost is negligible. ## The eleven concerns Each page covers one concern in depth — how it's wired, where the implementation lives, how modules consume it, and the gotchas worth knowing. ## Related - [Web building block](/docs/building-blocks/web/) — the composition root that wires every concern. - [Modules overview](/docs/modules/) — every module composes against these features. - [Architecture: dependency injection](/docs/architecture/dependency-injection/) — how `AddHeroPlatform` ties it together. --- # Background jobs > Hangfire 1.8 wired with tenant context preservation, DI-aware scoped activation, OpenTelemetry instrumentation, and a basic-auth-gated dashboard. Source: https://fullstackhero.net/docs/cross-cutting-concerns/background-jobs/ Hangfire 1.8 powers every background job in fullstackhero. The Jobs building block wraps it with four kit-specific filters: `FshJobFilter` preserves tenant + user context across enqueue → execute, `FshJobActivator` resolves jobs from a scoped DI container per execution, `HangfireTelemetryFilter` opens an OpenTelemetry activity per job, and `LogJobFilter` logs every state transition. Without `FshJobFilter`, a job dequeued on a background thread has no `HttpContext` and therefore no tenant. The filter captures the current Finbuckle tenant info and user id at enqueue time and stores them as job parameters; `FshJobActivator` reads them back when the job dequeues and pushes them onto `IMultiTenantContextSetter` / `ICurrentUserInitializer` in the job's DI scope. The job sees the same tenant and user the originating request did — without you writing any boilerplate. ## How it's wired ```csharp builder.AddHeroPlatform(o => o.EnableJobs = true); // turns on AddHeroJobs ``` `UseHeroPlatform` calls `UseHeroJobDashboard` for you, mounting the dashboard at `HangfireOptions:Route` (default `/jobs`). Storage provider falls out of `DatabaseOptions:Provider`: - `PostgreSQL` → `Hangfire.PostgreSql` - `MSSQL` → SQL Server storage The kit defaults to 5 workers, 30-second heartbeat, queues `default` + `email`, 30-second polling. ## Enqueueing a job Inject `IJobService` and call `Enqueue` / `Schedule`: ```csharp public sealed class SendInvoiceEmailCommandHandler(IJobService jobs) : ICommandHandler { public ValueTask Handle(SendInvoiceEmailCommand cmd, CancellationToken ct) { jobs.Enqueue(j => j.RunAsync(cmd.InvoiceId, CancellationToken.None)); return ValueTask.FromResult(Unit.Value); } } ``` Hangfire serializes the call signature; the activator resolves `SendInvoiceEmailJob` from a fresh DI scope when the job dequeues. The job can inject `DbContext`, `IMailService`, `ICurrentUser`, etc. ## Recurring jobs in the kit | Job (recurring job id) | Module | Cron | |---|---|---| | `MonthlyInvoiceJob` (`billing-monthly-invoices`) | Billing | `5 0 1 * *` — 00:05 UTC on the 1st of each month | | `PurgeOrphanedFilesJob` (`files-purge-orphans`) | Files | `0 * * * *` — hourly | | `PurgeDeletedFilesJob` (`files-purge-deleted`) | Files | `30 3 * * *` — 03:30 UTC daily | | `AuditRetentionJob` (`auditing-retention`) | Auditing | `30 3 * * *` default (configurable; the job is a no-op until `Auditing:Retention:Enabled` is true) | | `TenantExpiryScanJob` (`tenant-expiry-scan`) | Multitenancy | `0 2 * * *` — 02:00 UTC daily | The eventing outbox is **not** a Hangfire job — it's dispatched by the framework's `OutboxDispatcherHostedService`. (The host even ships a cleanup service that removes retired `{module}-outbox-dispatcher` recurring jobs from older deployments.) Register your own with `IRecurringJobManager`: ```csharp recurringJobs.AddOrUpdate( "my-hourly-job", job => job.RunAsync(CancellationToken.None), "0 * * * *", // top of every hour TimeZoneInfo.Utc); ``` ## Retry policies Per-job retry via the `[AutomaticRetry]` attribute on the job method: ```csharp // WebhookDispatchJob [AutomaticRetry( Attempts = 4, DelaysInSeconds = new[] { 30, 120, 600, 3600 }, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task DispatchAsync(/* ... */) { /* ... */ } ``` The Webhooks module uses this exact pattern — 5 attempts total (1 initial + 4 retries) with 30 s / 2 min / 10 min / 1 h exponential backoff. The Files purge jobs carry their own tuned attributes (`[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])]` for orphans; 2 attempts for deleted-file purges). ## Dashboard `UseHeroJobDashboard` (called inside `UseHeroPlatform`) mounts the Hangfire dashboard at `HangfireOptions:Route` (default `/jobs`), gated by `HangfireCustomBasicAuthenticationFilter`. ```jsonc { "HangfireOptions": { "UserName": "admin", "Password": "set-via-secrets", // min 12 chars — validated at startup "Route": "/jobs" } } ``` There are no safe defaults: `UserName` (min 3 chars) and `Password` (min 12 chars) are `[Required]` and validated at startup via `ValidateDataAnnotations().ValidateOnStart()` — the host won't boot with jobs enabled and empty credentials. Local dev gets `admin` / `Password123!` from `appsettings.Development.json` (the Aspire AppHost injects the same); set real values via env vars or a vault in prod. Always HTTPS in production; consider IP whitelisting in the reverse proxy on top of basic auth. ## Gotchas - **Hangfire serializes arguments.** Pass primitives (Guid, string, int) — not large object graphs. If you need state, look it up inside the job from the DI-resolved DbContext. - **`FshJobActivator` creates a new scope per job.** Don't try to share state via scoped services across job invocations; each gets a clean container. Use Valkey / DB for cross-job state. - **Stale-lock cleanup runs ~5s after host startup.** `HangfireStaleLockCleanupService` releases locks from servers that crashed without releasing. The delay avoids interfering with legitimate startup-time job acquisition. - **Background jobs and tenant context.** Enqueuing happens inside a request scope (so `ICurrentUser` works). Execution happens on a background thread (so it doesn't). `FshJobFilter` bridges this — but if you write your own filter or skip the kit's activator, restore the tenant context manually. ## Related - [Jobs building block](/docs/building-blocks/jobs/) — the implementation reference. - [Billing module](/docs/modules/billing/) — `MonthlyInvoiceJob` recurring schedule. - [Files module](/docs/modules/files/) — orphan + retention purge jobs. - [Webhooks module](/docs/modules/webhooks/) — `[AutomaticRetry]` with exponential backoff. --- # Caching > HybridCache (L1 in-memory + L2 Valkey) with OpenTelemetry instrumentation, stampede protection, and tag-based invalidation. Source: https://fullstackhero.net/docs/cross-cutting-concerns/caching/ `HybridCache` is the .NET 10 framework cache primitive. L1 is in-process memory; L2 is **Valkey** (a Redis-compatible, BSD-licensed Redis fork) — or `DistributedMemoryCache` for dev. The kit wraps it with `ObservableHybridCache` so every call emits OpenTelemetry spans plus metrics — `fsh.cache.hits`, `fsh.cache.misses`, `fsh.cache.invalidations`, and `fsh.cache.factory.duration`. The distributed L2 store is Valkey 8, which speaks the Redis protocol (RESP), so the .NET client (`StackExchange.Redis`) and all `CachingOptions:Redis` config keys are unchanged. In Aspire-based local dev, a **RedisInsight** browser is auto-wired and connected to the Valkey instance so you can inspect keys without any manual config — see [Aspire](/docs/deployment/aspire/). HybridCache coalesces concurrent identical requests into a single source fetch automatically. The first caller blocks on the source; the rest of the wave wait on the same task. You don't write lock code; just call `GetOrCreateAsync` and the cache does the right thing. ## How it's wired ```csharp // Program.cs (via AddHeroPlatform when EnableCaching = true) builder.AddHeroPlatform(o => o.EnableCaching = true); ``` `AddHeroCaching` reads `CachingOptions:Redis` from configuration. When set, L2 is `RedisCache` with a shared `IConnectionMultiplexer`. When empty, L2 falls back to `DistributedMemoryCache` (single-process; dev/test only). ## Using the cache Inject `HybridCache` and call `GetOrCreateAsync`: ```csharp public sealed class GetTenantConfigQueryHandler(HybridCache cache, ITenantConfigRepo repo) : IQueryHandler { public ValueTask Handle(GetTenantConfigQuery q, CancellationToken ct) => cache.GetOrCreateAsync( $"tenant-config:{q.TenantId}", async (innerCt) => await repo.LoadAsync(q.TenantId, innerCt).ConfigureAwait(false), tags: [$"tenant:{q.TenantId}"], cancellationToken: ct); } ``` Three things happen automatically: 1. **L1 hit** returns immediately without touching Valkey or the source. 2. **L1 miss / L2 hit** populates L1 and returns. 3. **Both miss** runs the factory, populates L1 + L2, returns. The `tags` array enables targeted invalidation: ```csharp // after the tenant's config changes await cache.RemoveByTagAsync($"tenant:{tenantId}", ct).ConfigureAwait(false); ``` This evicts every cache entry tagged with that tenant id across L1 + L2. The kit's key + tag conventions live in `CacheKeys` (Caching building block): short prefixed keys like `perm:u:{userId}`, `theme:t:{tenantId}`, `idem:t:{tenantId}:{key}`, and well-known tags — `permissions`, `themes`, `idempotency`, plus `CacheKeys.Tags.Tenant(id)` / `CacheKeys.Tags.User(id)` for scoped bulk invalidation. Follow the same shape for your own entries: tenant-scope the key where applicable, and always attach the tenant tag so tenant-wide purges catch your entries too. ## Configuration ```jsonc { "CachingOptions": { "Redis": "localhost:6379", "EnableSsl": false, "DefaultExpiration": "01:00:00", "DefaultLocalCacheExpiration": "00:02:00", "MaximumKeyLength": 1024, "MaximumPayloadBytes": 1048576 } } ``` | Option | Default | Purpose | |---|---|---| | `Redis` | empty | Connection string; empty falls back to in-memory L2 | | `EnableSsl` | null | Overrides the connection string's TLS setting; Aspire 13.x defaults Redis to TLS on the primary port, so set `false` when wiring the plain-TCP endpoint | | `DefaultExpiration` | 1 hour | TTL applied to both L1 + L2 | | `DefaultLocalCacheExpiration` | 2 minutes | L1-only TTL; bounds cross-node staleness after `RemoveByTag` on peers | | `MaximumKeyLength` | 1024 | Keys longer than this are rejected | | `MaximumPayloadBytes` | 1 MB | Larger payloads are skipped silently (warning logged) | ## Shared multiplexer When `CachingOptions:Redis` is set, `AddHeroCaching` connects once and registers a single shared `IConnectionMultiplexer` used by: - The **distributed cache** (`AddStackExchangeRedisCache` via a multiplexer factory). - **Data Protection** key persistence (`DataProtection-Keys`, app name `FSH.Starter`) — so auth cookies, reset/confirmation tokens, and antiforgery survive rolling deploys across instances. One connection pool per host for those consumers. The SignalR backplane (realtime) reads the same `CachingOptions:Redis` connection string but establishes its own connection. ## Who uses it in the kit - **Multitenancy module** caches resolved tenants in the distributed cache (Finbuckle's `DistributedCacheStore`, 60-minute TTL) and tenant themes in HybridCache (`TenantThemeService`, with `RemoveByTagAsync` per-tenant invalidation). - **Identity module** caches user permission sets in HybridCache (`UserPermissionService`, tag-invalidated; `RolePermissionSyncer` keeps role-permission state in sync at startup) and impersonation-grant revocation markers. - **Realtime** uses the distributed cache for the 3-second typing-indicator throttle per (channel, user) in `AppHub`. - **Idempotency** uses it to cache request responses by `Idempotency-Key`. ## Gotchas - **L1 has no backplane.** After `RemoveByTagAsync` on one instance, peer instances' L1 copies aren't invalidated. The `DefaultLocalCacheExpiration` (default 2 min) bounds staleness — don't set it too high if you rely on fast cross-node invalidation. - **Payload size cap is silent.** Entries larger than `MaximumPayloadBytes` are skipped with a warning log, not stored. Lookup will then miss every time. Log-search for "payload too large" warnings before assuming the cache is broken. - **`DistributedMemoryCache` is per-process.** Two instances of the API can't see each other's cache. Always check `CachingOptions:Redis` is set in production. ## Related - [Caching building block](/docs/building-blocks/caching/) — the implementation reference. - [Idempotency](/docs/cross-cutting-concerns/idempotency/) — uses HybridCache to replay requests. - [Multitenancy module](/docs/modules/multitenancy/) — uses HybridCache for tenant resolution. --- # Error handling > Global exception handler converts CustomException / NotFoundException / ForbiddenException / UnauthorizedException into RFC 9457 ProblemDetails responses. Source: https://fullstackhero.net/docs/cross-cutting-concerns/error-handling/ Every uncaught exception in fullstackhero turns into a `ProblemDetails` response (RFC 9457). One handler, one shape, every endpoint. React clients (`clients/admin` + `clients/dashboard`) parse the shape directly so error UI is consistent across the kit. The kit's idiom is throw-from-domain. Aggregates throw `CustomException` / `NotFoundException` from inside their methods; the handler catches at the boundary. You can layer a Result<T> pattern on top if you want, but you'd be adding ceremony — the global handler already does the HTTP translation cleanly. ## The exception hierarchy Four exception types ship in the `Core` building block (`FSH.Framework.Core.Exceptions`). `NotFoundException`, `ForbiddenException`, and `UnauthorizedException` all derive from `CustomException`, which carries an `HttpStatusCode` and an optional `ErrorMessages` list: | Type | HTTP Status | Use when | |---|---|---| | `CustomException(message, errors?, HttpStatusCode)` | Caller-specified (500 default) | Generic domain failure; pick the status | | `NotFoundException(message)` | 404 | Resource missing | | `ForbiddenException(message)` | 403 | Authenticated but not authorised | | `UnauthorizedException(message)` | 401 | Not authenticated | Plus `ValidationException` from FluentValidation (returned as 400 with the field errors). ## How the handler decides the response `GlobalExceptionHandler` (in the Web block) is registered via `services.AddExceptionHandler()` and implements `IExceptionHandler.TryHandleAsync(...)`. The type-switch: | Exception | Status | Notes | |---|---|---| | `FluentValidation.ValidationException` | 400 | Per-field `errors` map in the response | | `CustomException` (incl. subclasses) | `e.StatusCode` | `title` = exception type name, `detail` = message, `errors` = `ErrorMessages` when present | | `UnauthorizedAccessException` | 401 | BCL fallback | | `KeyNotFoundException` | 404 | BCL fallback | | `BadHttpRequestException` | Its own `StatusCode` (usually 400) | Malformed request — missing required header/param, unreadable or oversized body | | Anything else | 500 | Generic "An unexpected error occurred" — no internals leaked | The `BadHttpRequestException` mapping matters more than it looks: a request to an anonymous endpoint that omits a required parameter (like the `tenant` header binding) throws it, and before this mapping existed the client saw a misleading 500 instead of a 400 (issue #1245). Every response also carries `traceId` (the OpenTelemetry trace id, falling back to `HttpContext.TraceIdentifier`) and `correlationId` (the `X-Correlation-ID` request header when present) as ProblemDetails extensions. ## The response shape ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "title": "CustomException", "status": 409, "detail": "Cannot resolve a closed ticket.", "instance": "/api/v1/tickets/3d2c.../resolve", "traceId": "4a7d8e1f2c...", "correlationId": "..." } ``` Field-level errors from validation: ```http HTTP/1.1 400 Bad Request Content-Type: application/problem+json { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "Validation error", "status": 400, "detail": "One or more validation errors occurred.", "instance": "/api/v1/identity/users/register", "errors": { "Email": ["Email is not a valid address.", "Email is already in use."], "Password": ["Password must be at least 12 characters."] }, "traceId": "4a7d8e1f2c...", "correlationId": "..." } ``` The `traceId` matches the OpenTelemetry trace id, so client-side error logs cross-reference cleanly with server traces. ## Throwing from the domain The pattern is "the aggregate enforces the invariant; the handler catches the exception." ```csharp // Aggregate public void Resolve(string? resolutionNote) { if (Status == TicketStatus.Closed) throw new CustomException("Cannot resolve a closed ticket.", null, HttpStatusCode.Conflict); Status = TicketStatus.Resolved; // ... } // Handler — no try/catch public async ValueTask Handle(ResolveTicketCommand cmd, CancellationToken ct) { var ticket = await _db.Tickets.FindAsync([cmd.TicketId], ct).ConfigureAwait(false) ?? throw new NotFoundException($"Ticket {cmd.TicketId} not found."); ticket.Resolve(cmd.ResolutionNote); await _db.SaveChangesAsync(ct).ConfigureAwait(false); return Unit.Value; } ``` No try/catch in the handler. The global handler maps the Conflict `CustomException` to a 409 ProblemDetails; the `NotFoundException` becomes a 404. The endpoint's caller sees a structured response and the kit's React client renders the right toast / inline error. ## Validation errors FluentValidation runs via the `ValidationBehavior` Mediator pipeline before the handler. A failing validator makes the behavior throw `ValidationException`; the global handler converts it into a 400 with per-field errors: ```csharp public sealed class CreateProductCommandValidator : AbstractValidator { public CreateProductCommandValidator() { RuleFor(x => x.Sku).NotEmpty().MaximumLength(64); RuleFor(x => x.Name).NotEmpty().MaximumLength(255); RuleFor(x => x.PriceAmount).GreaterThanOrEqualTo(0); // ... } } ``` The response carries an `errors` map keyed by field name with arrays of message strings. React clients render them inline next to the field. ## What never reaches the client Stack traces and inner exception details are **never** included in the response — there is no "development mode" flag that adds them. The handler pushes the stack trace, title, detail, and status onto the Serilog `LogContext` instead, so the full picture lands in your logs while the client only sees the structured ProblemDetails. Unexpected exceptions (the 500 fallthrough) get a fixed generic message — internal type names and messages stay server-side. ## Anti-patterns to avoid - **`catch { return BadRequest(); }` in handlers.** The global handler already does this; wrapping it just hides intent. Throw the right exception type from the domain. - **`throw new Exception("...")` from a handler.** Use one of the typed exceptions. A bare `Exception` falls through to "500 Internal Server Error" with a generic message — useless for the client and harder to debug. - **Returning `IActionResult` / `Results.X(...)` from a handler.** Handlers return DTOs. The endpoint adapts to `IResult` via the endpoint builder. Keeping handlers DTO-only means the same handler is unit-testable without HTTP context. ## Logging vs responding The handler writes the HTTP response **and** logs the exception via `ILogger` (structured message template — path, status code, title), with the exception detail and stack trace attached through `LogContext` properties. If a customer reports "I got an error", you ask for the `traceId`, look it up in your log aggregator, and you have the full picture. The kit's `MediatorTracingBehavior` marks the command/query span as errored and tags it with `exception.type` and `exception.message` before re-throwing, so you can graph error rates per exception type and alert on spikes. ## Related - [Core building block](/docs/building-blocks/core/) — `CustomException` and friends. - [Web building block](/docs/building-blocks/web/) — `GlobalExceptionHandler`, validation behavior. - [Observability](/docs/cross-cutting-concerns/observability/) — `traceId` linking responses to server traces. - [Vertical Slice Architecture](/docs/architecture/vertical-slice/) — the handler conventions that work with this error model. --- # Feature flags > Microsoft.FeatureManagement with a custom TenantFeatureFilter so flags can be enabled per tenant. Source: https://fullstackhero.net/docs/cross-cutting-concerns/feature-flags/ `Microsoft.FeatureManagement.AspNetCore` is the kit's feature-flag system. The kit ships a custom `TenantFeatureFilter` so flags can be enabled for specific tenants — useful for staged rollouts, beta cohorts, kill switches per customer, and per-environment toggles without code changes. A feature flag's job is to ship a risky change behind a switch you can flip if it goes wrong. Once the feature is stable, **remove the flag**. Flags that linger forever become a different (worse) kind of dead code than the alternative they were meant to replace. ## Opt in ```csharp builder.AddHeroPlatform(o => o.EnableFeatureFlags = true); ``` `AddHeroPlatform` registers `Microsoft.FeatureManagement` services plus `TenantFeatureFilter` so per-tenant evaluation works. Feature flags are **off by default** — the shipped host (`FSH.Starter.Api/Program.cs`) doesn't enable them, so flip the toggle before declaring any flags. ## Declaring flags Flags live in `appsettings.json` under `FeatureManagement`. Three example shapes: ```jsonc { "FeatureManagement": { // 1. simple on/off "BetaCheckoutFlow": true, // 2. tenant-scoped — only enabled for listed tenants "BetaProductImagesV2": { "EnabledFor": [ { "Name": "Tenant", "Parameters": { "AllowedTenants": ["acme", "globex"] } } ] }, // 3. multi-filter (tenant + percentage rollout) "NewSearchUi": { "EnabledFor": [ { "Name": "Tenant", "Parameters": { "AllowedTenants": ["acme"] } }, { "Name": "Microsoft.Percentage", "Parameters": { "Value": 10 } } ] } } } ``` The `Tenant` filter is the kit's `TenantFeatureFilter` (alias `Tenant`, parameter `AllowedTenants`, case-insensitive tenant-id match); the percentage filter is built into `Microsoft.FeatureManagement`. You can stack filters — they OR together by default (any filter passing enables the flag). ## Checking a flag Inject `IFeatureManager` and call `IsEnabledAsync`: ```csharp public sealed class GetProductByIdQueryHandler( ICatalogDbContext db, IFeatureManager features, IImageEnricher images) : IQueryHandler { public async ValueTask Handle(GetProductByIdQuery q, CancellationToken ct) { var product = await db.Products.FindAsync([q.ProductId], ct).ConfigureAwait(false); if (await features.IsEnabledAsync("BetaProductImagesV2").ConfigureAwait(false)) return await images.EnrichV2Async(product, ct).ConfigureAwait(false); return ProductResponse.From(product); } } ``` For tenant-scoped flags, `TenantFeatureFilter` resolves the tenant from the Finbuckle multi-tenant context (falling back to the `tenant` request header if the context isn't resolved yet) and matches it against `AllowedTenants`. No tenant resolvable → the filter says no. No flag definition at all → `IsEnabledAsync` returns `false`. ## Gating endpoints For all-or-nothing endpoint gating, use the kit's `.RequireFeature(...)` extension, which attaches `FeatureGateEndpointFilter`: ```csharp endpoints.MapGet("/catalog/products/recommended", handler) .RequirePermission(perm) .RequireFeature("RecommendationsEndpoint"); ``` When the flag is off, the endpoint returns **404 Not Found** — to callers, a gated-off endpoint doesn't exist. Use this for shipping new endpoints behind a flag without exposing them broadly. ## Common patterns ### Staged rollout Enable for one tenant first, then add more once you're confident: ```jsonc { "FeatureManagement": { "NewBilling": { "EnabledFor": [ { "Name": "Tenant", "Parameters": { "AllowedTenants": ["test-tenant"] } } ] } } } ``` Re-deploy with more tenants in the array as confidence grows; eventually drop the filter and set the flag to `true` globally. ### Kill switch ```jsonc { "FeatureManagement": { "ChatRealtimeEnabled": true } } ``` Set to `false` in an incident to instantly disable the SignalR push path without redeploying. Have the handler fall back to polling, or return a graceful "feature temporarily unavailable" response. ### Per-environment `appsettings.Production.json` overrides `appsettings.json`. Set flags to `false` for production until they ship: ```jsonc // appsettings.Production.json { "FeatureManagement": { "BetaCheckoutFlow": false } } ``` ## Where flag state lives In **configuration**. The kit doesn't ship a database-backed flag store. That's deliberate: editing JSON config is cheap, auditable through git history, and doesn't require a separate management UI. Related but separate: billing/quota plans carry an `ActiveFeatureFlags` limit (`QuotaResource.ActiveFeatureFlags` — see `QuotaOptions:Plans` and the admin plan form). That's a metering dimension for plan design; nothing currently enforces it against your `FeatureManagement` definitions. If you outgrow JSON — you need runtime flag toggles without redeploy, or per-user (not per-tenant) flags — wire a custom `IFeatureDefinitionProvider` against your store of choice (Redis, EF Core, ConfigCat, LaunchDarkly). The kit's `TenantFeatureFilter` pattern is the template for custom filters. ## Removing a flag When a flag has been on globally for a while and the alternative is gone: 1. Delete the flag definition from `appsettings.json`. 2. Remove the `IFeatureManager.IsEnabledAsync` call in the handler — collapse the conditional to the always-on branch. 3. Delete the dead alternative path. Don't skip step 3. Lingering "old" branches are technical debt and a security risk (un-tested code that might still be reachable). ## Related - [Multitenancy module](/docs/modules/multitenancy/) — the tenant resolution that `TenantFeatureFilter` reads. - [Production checklist](/docs/security/) — flag hygiene before a major release. - [Web building block](/docs/building-blocks/web/) — `EnableFeatureFlags` toggle. --- # Health checks > Liveness + readiness endpoints — per-module database checks, Valkey, Hangfire, tenant migrations — for Kubernetes / Docker Compose / load balancer probes. Source: https://fullstackhero.net/docs/cross-cutting-concerns/health-checks/ Two health endpoints ship out of the box: `GET /health/live` for liveness, `GET /health/ready` for readiness. Both are anonymous and exempt from rate limiting. The kit registers checks for every dependency the host needs to function — one database check per module `DbContext`, Valkey (a Redis-compatible, BSD-licensed Redis fork) when caching is enabled, Hangfire when jobs are enabled, and a per-tenant migration check that reports whether every tenant DB is on the latest schema. Liveness asks "is the process alive?" — restart if it fails. Readiness asks "is the process ready to take traffic?" — stop sending requests if it fails. The kit's `/health/live` runs **zero** dependency checks (just proves the process responds); `/health/ready` runs **every** registered check. ## What's checked | Check | Source | When registered | |---|---|---| | `self` | `AddHeroPlatform` | Always | | `db:{module}` (e.g. `db:identity`, `db:catalog`, `db:billing`…) | `AddDbContextCheck` in each module's registration | One per module DbContext | | `redis` | Kit's `RedisHealthCheck` | When `EnableCaching = true` **and** `CachingOptions:Redis` is set | | `hangfire` | Kit's `HangfireHealthCheck` | When `EnableJobs = true` | | `db:tenants-migrations` | Kit's `TenantMigrationsHealthCheck` (Multitenancy module) | Always (with the Multitenancy module) | There is no storage/MinIO health check — blob storage failures surface through the Files module's own error handling, not readiness. ## How to wire probes ### Kubernetes ```yaml livenessProbe: httpGet: path: /health/live port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /health/ready port: 8080 initialDelaySeconds: 10 periodSeconds: 5 ``` ### Docker Compose ```yaml services: api: image: fsh-api:latest healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"] interval: 10s timeout: 5s retries: 3 ``` ### Cloud load balancers Point them at `/health/ready` — they'll only route traffic to instances that report ready. ## Response shape The kit serializes its own compact shape (not the default ASP.NET Core health JSON): ```http GET /health/live HTTP/1.1 HTTP/1.1 200 OK Content-Type: application/json { "status": "Healthy", "results": [] } ``` ```http GET /health/ready HTTP/1.1 HTTP/1.1 200 OK Content-Type: application/json { "status": "Healthy", "results": [ { "name": "self", "status": "Healthy", "description": null, "durationMs": 0.01 }, { "name": "db:identity", "status": "Healthy", "description": null, "durationMs": 12.3 }, { "name": "db:catalog", "status": "Healthy", "description": null, "durationMs": 8.7 }, { "name": "redis", "status": "Healthy", "description": null, "durationMs": 1.9 }, { "name": "db:tenants-migrations", "status": "Healthy", "description": "All tenants are at the head migration.", "durationMs": 41.0, "details": { /* per-tenant data */ } } ] } ``` When any check fails, the overall response is HTTP 503 — with the **same body shape**, so operators can see exactly which check failed while probe consumers key off the status code alone. ## Adding your own check Implement `IHealthCheck` and register it during module startup: ```csharp public sealed class CustomerApiHealthCheck(HttpClient http) : IHealthCheck { public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct) { try { using var resp = await http.GetAsync("/health", ct).ConfigureAwait(false); return resp.IsSuccessStatusCode ? HealthCheckResult.Healthy() : HealthCheckResult.Degraded($"Upstream returned {(int)resp.StatusCode}"); } catch (Exception ex) { return HealthCheckResult.Unhealthy("Upstream unreachable", ex); } } } // register in the module's ConfigureServices services.AddHealthChecks().AddCheck("customer-api"); ``` Every check registered with `AddHealthChecks()` automatically participates in `/health/ready` — liveness runs no checks, so there's nothing to tag or filter. Keep slow checks (cross-region pings, third-party APIs) out of the registry or make them cheap; readiness latency is probe latency. ## Tenant migrations check `TenantMigrationsHealthCheck` is unusual — it iterates every tenant, sets that tenant's Finbuckle context, and asks EF Core for pending migrations. Per-tenant detail lands in the check's `data`: ```json { "name": "db:tenants-migrations", "status": "Unhealthy", "description": "Tenant schema is not at head — pending migrations for tenant(s): acme. Run FSH.Starter.DbMigrator to apply pending migrations.", "details": { "acme": { "name": "Acme", "isActive": true, "hasPendingMigrations": true, "pendingMigrations": ["20260601_AddX"] } } } ``` A tenant with pending migrations (or one whose probe throws) makes the check `Unhealthy`, which makes `/health/ready` return 503 — Kubernetes keeps the pod out of rotation until the standalone `FSH.Starter.DbMigrator` catches the schema up. That's deliberate: the DB is never migrated at API startup, so a pod running newer code against an older schema must not take traffic. ## What health checks don't tell you - **Per-endpoint health** — `/health/ready` reports infrastructure state, not "is this specific endpoint working." Use synthetic monitoring (probing real endpoints with known payloads) for that. - **Latency** — health is binary; latency is a spectrum. Use the OpenTelemetry HTTP server metrics for latency SLOs. - **Partial tenant outages** — the migrations check covers schema drift, but a single tenant's connectivity problem in a tenant-per-database setup needs its own aggregation if your SLA covers per-tenant availability. ## Related - [Observability](/docs/cross-cutting-concerns/observability/) — for latency + error-rate signals. - [Deployment guide](/docs/deployment/) — how the probes are wired in the kit's docker-compose and Kubernetes manifests. - [Multitenancy module](/docs/modules/multitenancy/) — owns the per-tenant migration check. --- # HTTP resilience > Polly v8 via Microsoft.Extensions.Http.Resilience — retry, circuit breaker, timeouts — opt-in per HttpClient with the kit's AddHeroResilience extension. Source: https://fullstackhero.net/docs/cross-cutting-concerns/http-resilience/ `Microsoft.Extensions.Http.Resilience` (Polly v8 under the hood) backs the kit's outbound HTTP calls. The Web block ships an `AddHeroResilience` extension that attaches the standard resilience pipeline — **retry with backoff** on transient failures, a **circuit breaker** to fail fast when a downstream is consistently broken, and **total + per-attempt timeouts** to prevent hung requests from holding threadpool slots — to any `IHttpClientBuilder`, configured from one appsettings section. You shouldn't see `for (var i = 0; i < 3; i++) { try { ... } catch { ... } }` anywhere in the kit. Register a named client and chain `.AddHeroResilience(builder.Configuration)` — the pipeline does retry, breaking, and timeouts for you, with config-tunable knobs. ## How it's wired Resilience is **opt-in per client**, not global. The Webhooks module is the shipped example: ```csharp // WebhooksModule.ConfigureServices builder.Services.AddHttpClient("Webhooks") .AddHeroResilience(builder.Configuration); ``` `AddHeroResilience` reads the `HttpResilienceOptions` section and calls `AddStandardResilienceHandler` with those values. When you add your own outbound integration, do the same — name the client, chain the extension. ## What the standard pipeline does ``` [Outer] Total request timeout (30s default) ↓ [Retry] Up to 3 attempts on transient errors with backoff ↓ [Breaker] Circuit breaker — opens at 50% failure ratio, 5s break ↓ [Attempt] Per-attempt timeout (10s default) ↓ HttpClient.SendAsync ``` ## Configuration ```jsonc { "HttpResilienceOptions": { "Enabled": true, // default — false skips the handler entirely "MaxRetryAttempts": 3, "MedianFirstRetryDelay": "00:00:01", // first retry delay; backoff grows from here "TotalTimeout": "00:00:30", // whole request incl. retries "AttemptTimeout": "00:00:10", // each individual attempt "CircuitBreakerBreakDuration": "00:00:05", "CircuitBreakerFailureRatio": 0.5, "CircuitBreakerMinimumThroughput": 10 } } ``` These are the code defaults — the shipped `appsettings.json` doesn't carry the section, so you only add it when tuning. One section configures every client that opted in via `AddHeroResilience`; for genuinely different upstreams (a slow third-party API), register that client with its own `AddStandardResilienceHandler(opts => ...)` lambda instead. ## How resilience integrates with `[AutomaticRetry]` Hangfire's `[AutomaticRetry]` and the HTTP resilience pipeline are **two separate retry layers**. The kit uses both, deliberately: - **HTTP-layer retry** handles transient network blips (5xx, timeouts) within a single job attempt — fast, in-process, no job queue churn. - **Job-layer retry** handles longer outages — minutes-to-hours-scale backoff while the Hangfire job stays in the queue. For the Webhooks module: each delivery attempt runs through the Polly pipeline (up to 3 in-process retries, seconds apart). If the attempt still fails, the Hangfire job fails and `[AutomaticRetry(Attempts = 4, DelaysInSeconds = [30, 120, 600, 3600])]` re-runs it — up to 5 job-level attempts spread over ~1 h 12 min. ## When NOT to retry The standard resilience handler retries on **transient** failures only: - HTTP 408 Request Timeout - HTTP 429 Too Many Requests - HTTP 500+ server errors - Connection / timeout exceptions (`HttpRequestException`, attempt timeouts) It does **not** retry permanent client errors — 400, 401, 403, 404, 405, 409 — because retrying won't change the answer. For specific 4xx codes that *should* be treated as transient (rare — e.g. an API that returns 423 Locked on transient contention), configure `ShouldHandle` with a custom predicate on a per-client `AddStandardResilienceHandler` registration. ## Circuit breaker The breaker opens when the failure ratio crosses `CircuitBreakerFailureRatio` (50%) over at least `CircuitBreakerMinimumThroughput` (10) requests. While open, requests fail fast with `BrokenCircuitException` instead of waiting for the timeout. After `CircuitBreakerBreakDuration` (5 s), the breaker half-opens — a trial request decides whether to close (resume normal traffic) or stay open (downstream still broken). This is what stops a flaky downstream from cascading into your threadpool. Watch breaker open / close events in your traces; opens that don't half-close mean the downstream is genuinely down and needs human attention. ## Hedging (for read-only calls) The kit doesn't ship a hedging pipeline, but `Microsoft.Extensions.Http.Resilience` includes one if you need it. Hedging fires parallel requests; the first response wins, the rest are cancelled. Trades load for latency: ```csharp services.AddHttpClient("LatencySensitiveReads") .AddStandardHedgingHandler(opts => { opts.Hedging.MaxHedgedAttempts = 2; // up to 2 parallel requests opts.Hedging.Delay = TimeSpan.FromMilliseconds(200); // wait 200ms before firing the hedge }); ``` Don't enable it globally — it multiplies the downstream's load. Reserve it for high-percentile-sensitive reads against upstreams that can absorb the extra volume. ## Gotchas - **Configure timeouts on the pipeline, not the client.** The pipeline's `TotalTimeout` is the effective bound; a mismatched `HttpClient.Timeout` just adds a second, confusing limit. - **Retry + non-idempotent requests = duplicate creates.** If you POST and retry on 502, you might create two resources. Combine with [Idempotency-Key](/docs/cross-cutting-concerns/idempotency/) on every retried POST. - **Look at `InnerException`.** Polly's timeout/breaker exceptions wrap the underlying transport failure. - **OpenTelemetry sees each attempt as its own HTTP client span.** A request that succeeds on the third try shows three spans in the trace. This is correct; ignore the noise in dashboards. ## Related - [Idempotency](/docs/cross-cutting-concerns/idempotency/) — required when retrying non-idempotent operations. - [Webhooks module](/docs/modules/webhooks/) — the shipped consumer. - [Background jobs](/docs/cross-cutting-concerns/background-jobs/) — Hangfire `[AutomaticRetry]` complements HTTP-layer retry. --- # Idempotency > Idempotency-Key header support with HybridCache-backed replay protection across instances. Source: https://fullstackhero.net/docs/cross-cutting-concerns/idempotency/ Network is unreliable. A `POST /orders` request might submit, time out before the response arrives, and get retried. Without idempotency, the retry creates a second order. With it, the retry returns the same response as the first. The kit ships an `Idempotency-Key` header convention. Callers send a fresh key with every request that mutates state; the kit caches the response (status + body) keyed on `(tenant, key)` for a configurable window. Duplicate requests within the window return the cached response without re-executing the handler. Checkout, send-message, create-resource flows. Skip it on flows that are naturally idempotent (PUT-with-id, PATCH-with-version) where the caller controls the determinism. In the kit, the Billing and Catalog modules already apply it to their mutating endpoints — `CreatePlanEndpoint`, `MarkInvoicePaidEndpoint`, `CreateBrandEndpoint`, and friends. ## Opt an endpoint in The Web block ships a `.WithIdempotency()` extension on `RouteHandlerBuilder`: ```csharp endpoints.MapPost("/orders", handler) .RequirePermission(perm) .WithIdempotency(); ``` That's the whole opt-in. The kit's `IdempotencyEndpointFilter` (an `IEndpointFilter`) wraps the handler: 1. **Before**: read the `Idempotency-Key` header. If present, build the cache key `idem:t:{tenant}:{key}` (tenant from the caller's `tenant` claim, `"global"` when absent) and probe the cache. 2. **If cached**: write the cached status + body, set an `Idempotency-Replayed: true` response header, and skip the handler entirely. 3. **If not**: invoke the handler, then store the serialized result + status code under the cache key with a TTL. If no header is sent, the filter is a no-op — the endpoint behaves like an ordinary one. Keys longer than `MaxKeyLength` are rejected with a 400 before the handler runs. The probe reads through `IDistributedCache` directly (a real get-or-null, bypassing L1 — replays are rare so L1 warmth has little value); the write goes through `HybridCache.SetAsync` with tags (`idempotency` + the tenant tag) so tag-based purges work. Caching the response is **best-effort**: if the store write fails, the filter logs a warning and the request still succeeds. ## Configuration ```jsonc { "IdempotencyOptions": { "HeaderName": "Idempotency-Key", // default "DefaultTtl": "1.00:00:00", // 24 hours (default) "MaxKeyLength": 128 // default } } ``` Idempotency is **on by default** in `FshPlatformOptions` (`EnableIdempotency = true`); `AddHeroPlatform` binds the options. The replay store is HybridCache, so the L2 (Valkey) backing means cached responses survive across instances. Without Valkey you get per-instance idempotency, which is OK for dev but not for multi-instance production. ## What clients send ```http POST /api/v1/orders HTTP/1.1 Authorization: Bearer tenant: acme Idempotency-Key: 7f3d9a2c-1b8e-4f5a-9c0d-2e8f6b1a3d4c Content-Type: application/json { "items": [...] } ``` The key should be a fresh UUID per *logical* request: - A retry of the same request uses the **same** key — gets the cached response (and the `Idempotency-Replayed: true` header, so clients can tell). - A different request (even from the same client) uses a **fresh** key — gets a fresh response. Most HTTP client libraries can generate keys automatically; for `HttpClientFactory` consumers, a `DelegatingHandler` is a clean place to do it. ## What it doesn't do - **It doesn't dedupe content.** If the client sends two requests with **different** keys and identical bodies, both create resources. Idempotency keys are per-request-attempt identifiers, not content hashes. (Use a content hash + lookup if you want content dedupe — but it's a different feature.) - **It doesn't span the request boundary forever.** After `DefaultTtl` expires, a replayed request runs again. Set the TTL to whatever is realistic for your client retry strategy — 24 h is the default; longer is reasonable for batch / async flows. - **It doesn't catch partial failures.** If the handler runs, mutates state, then crashes before the response is captured, the cache won't have an entry. The retry runs again. Combine idempotency with idempotent domain operations for true safety. ## Domain-level idempotency The kit's domain already has several naturally-idempotent operations: - `Notification.MarkRead()` — `ReadAtUtc ??= now`; second call is a no-op. - `Ticket.Reopen()` — guarded against illegal source states; second call from a valid state is harmless. - `WebhookSubscription` deactivation — sets `IsActive = false`; idempotent. - Find-or-create DM channels — `DirectKey` uniqueness makes "find or create" race-safe. Layer `Idempotency-Key` on top of these for full retry safety across both transport and domain failure modes. ## Gotchas - **The cache key includes tenant**, so two tenants using the same idempotency key get separate responses. This is correct; tenants are independent. - **The cache key does NOT include the route.** Reusing one key across two different idempotent endpoints within the same tenant replays the first endpoint's response on the second. Always generate a fresh key per logical request — never share keys across operations. - **Cache miss after restart is normal.** If Valkey isn't configured, the in-memory L2 starts empty after a restart — replayed requests run again. Always use Valkey in production. ## Related - [Caching](/docs/cross-cutting-concerns/caching/) — the HybridCache that backs the replay store. - [HTTP resilience](/docs/cross-cutting-concerns/http-resilience/) — caller-side retry policy that needs idempotency to be safe. - [Web building block](/docs/building-blocks/web/) — the filter implementation. --- # Observability > Serilog structured logging + OpenTelemetry traces and metrics over OTLP — with Mediator, EF Core, Npgsql, Redis, and Hangfire instrumentation. Source: https://fullstackhero.net/docs/cross-cutting-concerns/observability/ Observability is on by default in fullstackhero (`EnableOpenTelemetry = true` in `FshPlatformOptions`). Every HTTP request is traced; every Mediator command is traced; every EF Core / Npgsql query is traced; every Hangfire job is traced. Structured logs flow through Serilog with an HTTP-context enricher, and traces + metrics + logs cross the OTLP exporter to whatever collector you point it at (Aspire dashboard locally; Honeycomb / Datadog / Grafana / Tempo / Loki in prod). A Mediator pipeline behavior (`MediatorTracingBehavior`) means every `mediator.Send(new MyCommand(...))` opens an `Activity` automatically. Same for HTTP, EF, and Hangfire — handled by the standard instrumentation packages plus two kit-specific hooks. The kit's job is wiring; you focus on what to log explicitly. ## What gets observed without you doing anything | Source | Instrumentation | Telemetry | |---|---|---| | ASP.NET Core | `AddAspNetCoreInstrumentation` | Request traces (health paths filtered out) + server metrics | | HttpClient | `AddHttpClientInstrumentation` | Outbound HTTP traces + metrics | | EF Core | `AddEntityFrameworkCoreInstrumentation` | Query traces | | Postgres | `AddNpgsql` / `AddNpgsqlInstrumentation` | Driver-level traces + metrics | | Valkey | `AddRedisInstrumentation` | Cache call traces (command text suppressed when `Data:FilterRedisCommands` is on) | | .NET runtime | `AddRuntimeInstrumentation` | GC, threadpool, exception metrics | | Mediator | `MediatorTracingBehavior` (kit-specific) | A span per command/query with `mediator.request_type`, error status + `exception.type`/`exception.message` on failure | | Hangfire | `HangfireTelemetryFilter` (kit-specific, source `FSH.Hangfire`) | A span per job execution with `hangfire.job_id`, `hangfire.job_type`, `hangfire.job_method` | | HybridCache | `ObservableHybridCache` decorator (source/meter `FSH.Caching`) | Spans per cache op + hit/miss/invalidation counters, factory duration | Requests to `/health*` and `/alive` are excluded from request tracing so probes don't drown your trace store. ## Logging — Serilog with enrichers `AddHeroLogging` configures Serilog (reading the `Serilog` section of appsettings) and adds the kit's `HttpRequestContextEnricher`, which stamps every log event written during a request with: - `RequestMethod`, `RequestPath`, `UserAgent` - `UserId`, `Tenant`, `UserEmail` — when the request is authenticated The shipped `Serilog` config adds `FromLogContext`, `WithMachineName`, `WithThreadId`, `WithCorrelationId`, `WithProcessId`, and `WithProcessName` enrichers, writes to the console, and quiets the chatty framework categories (`Microsoft`, `Hangfire`, `Finbuckle`, EF Core) to warning-or-worse. ```csharp _logger.LogInformation("Created product {Sku} for tenant {TenantId}", product.Sku, tenantId); ``` Structured logging only — message templates or `[LoggerMessage]` source generators, never string interpolation (the build treats analyzer warnings as errors). **Log export over OTLP is automatic.** Serilog owns the logging pipeline, so the kit exports logs from *inside* Serilog via the OpenTelemetry sink — added automatically when either the `OTEL_EXPORTER_OTLP_ENDPOINT` env var is present (Aspire injects it) or `OpenTelemetryOptions:Exporter:Otlp:Enabled` is true with an endpoint configured. The sink reuses the same `service.name` as traces/metrics so the dashboard groups all three signals under one resource. ## Tracing — what shows up in traces A typical request trace: ``` HTTP POST /api/v1/catalog/products [12ms] ├─ Mediator CreateProductCommand [11ms] │ ├─ EF: SELECT ... FROM "Brands" ... [2ms] │ ├─ EF: SELECT ... FROM "Categories" ... [1ms] │ └─ EF: INSERT INTO "Products" ... [4ms] └─ HTTP response 201 Created ``` Every step is a span with start time, duration, and structured attributes. Trace context propagates to outbound HTTP calls (webhook deliveries, third-party APIs) so a single trace id follows the request across processes. By default `Data:FilterEfStatements` / `Data:FilterRedisCommands` suppress raw statement text to keep PII and noise out of spans. ## Metrics — what's measured - **Runtime metrics** — GC counts, threadpool size, exception count, allocation rate. - **HTTP server metrics** — `http.server.duration` histogram with kit-tuned buckets (10 ms → 5 s by default; override via `Http:Histograms:BucketBoundaries`). - **Cache metrics** — `fsh.cache.hits`, `fsh.cache.misses`, `fsh.cache.invalidations` (counters) and `fsh.cache.factory.duration` (histogram, ms) from `ObservableHybridCache`. - **Module metrics** — the host opts module meters in via `Metrics:MeterNames` (shipped: `FSH.Modules.Identity`, `FSH.Modules.Multitenancy`, `FSH.Modules.Auditing`). - **Custom metrics** — create a `Meter` and add its name to `Metrics:MeterNames`; the OTel registration picks it up. The metric export interval defaults to 10 s (instead of the SDK's 60 s) so dashboards populate quickly after a restart; set `OTEL_METRIC_EXPORT_INTERVAL` to override. ## Configuration The section is `OpenTelemetryOptions`: ```jsonc { "OpenTelemetryOptions": { "Enabled": true, "Tracing": { "Enabled": true }, "Metrics": { "Enabled": true, "MeterNames": [ "FSH.Modules.Identity", "FSH.Modules.Multitenancy", "FSH.Modules.Auditing" ] }, "Exporter": { "Otlp": { "Enabled": false, // dev default — Aspire injects its own endpoint via env vars "Endpoint": "http://localhost:4317", "Protocol": "grpc" // or "http/protobuf" } }, "Jobs": { "Enabled": true }, // Hangfire spans "Mediator": { "Enabled": true }, // MediatorTracingBehavior "Http": { "Histograms": { "Enabled": true } }, "Data": { "FilterEfStatements": true, // keep SQL text out of spans "FilterRedisCommands": true // keep Redis command text out of spans } } } ``` Two things are deliberately **not** config keys: - **Service name** — resolved from the `OTEL_SERVICE_NAME` env var (Aspire and most collectors inject it) with the application name as fallback, so the kit's telemetry groups under the same resource the orchestrator already knows. - **The Aspire endpoint** — when `OTEL_EXPORTER_OTLP_ENDPOINT` is present in the environment, the kit exports to it *even if* `Exporter:Otlp:Enabled` is false, and lets the SDK read endpoint/protocol/headers from the standard `OTEL_EXPORTER_OTLP_*` env vars. That's how traces show up live in the Aspire dashboard with zero config. ## What to add yourself The kit ships **infrastructure tracing**. You add **business tracing** — spans for the long-running steps your handlers do that aren't already covered: ```csharp public sealed class GenerateInvoicesCommandHandler(ActivitySource source /* , ... */) : ICommandHandler { public async ValueTask Handle(GenerateInvoicesCommand cmd, CancellationToken ct) { using var activity = source.StartActivity("GenerateInvoicesForPeriod"); activity?.SetTag("period.year", cmd.PeriodYear); activity?.SetTag("period.month", cmd.PeriodMonth); // ... } } ``` The kit registers a shared `ActivitySource` (named after the application) in DI and subscribes the tracer to it — inject it rather than newing your own, or add your custom source name in the Web block's tracing registration. Custom counters for domain-specific metrics ("invoices issued per period", "webhooks delivered per tenant") give you product dashboards out of the box — register the meter name in `Metrics:MeterNames`. ## Related - [Auditing module](/docs/modules/auditing/) — different concern (compliance/forensic), but uses the same trace/correlation ids. - [Background jobs](/docs/cross-cutting-concerns/background-jobs/) — Hangfire telemetry filter. - [Web building block](/docs/building-blocks/web/) — `AddHeroLogging` + `AddHeroOpenTelemetry`. --- # Rate limiting > ASP.NET Core RateLimiting with chained per-tenant / per-user / per-IP global limiters plus an "auth" policy on login, refresh, password and registration endpoints. Source: https://fullstackhero.net/docs/cross-cutting-concerns/rate-limiting/ `Microsoft.AspNetCore.RateLimiting` lives in the Web block (`BuildingBlocks/Web/RateLimiting/`). The kit ships **two layers**: 1. A **global limiter** — three chained fixed-window limiters partitioned per **tenant**, per **user**, and per **IP** — applied to every request when rate limiting is enabled. 2. A named **`auth` policy** that throttles authentication-flow endpoints — login, refresh, forgot-password, reset-password, confirm-email, resend-confirmation, self-register — for brute-force protection where it matters most. Rate limiting protects against abuse — bursts of requests from a small number of callers. Quotas (the Quota building block) enforce per-tenant resource budgets across a calendar month. Different concerns, different mechanisms; the kit ships both. ## How it's configured Everything binds from the `RateLimitingOptions` section. These are the shipped values (also the code defaults): ```jsonc { "RateLimitingOptions": { "Enabled": false, // false in appsettings.json (dev); true in appsettings.Production.json "Tenant": { "PermitLimit": 1000, "WindowSeconds": 60, "QueueLimit": 0 }, "User": { "PermitLimit": 200, "WindowSeconds": 60, "QueueLimit": 0 }, "Ip": { "PermitLimit": 300, "WindowSeconds": 60, "QueueLimit": 0 }, "Auth": { "PermitLimit": 10, "WindowSeconds": 60, "QueueLimit": 0 } } } ``` All four buckets are fixed-window limiters with no queueing (`QueueLimit: 0`) — excess requests get an immediate 429. Rate limiting is **off in local dev** (`appsettings.json`) and **on in production** (`appsettings.Production.json`). ## The global limiter When enabled, `AddHeroRateLimiting` builds a chained `GlobalLimiter` (`PartitionedRateLimiter.CreateChained`) — a request must pass **all three** dimensions: | Dimension | Partition key | Skipped when | |---|---|---| | Tenant | `tenant:{tenant claim}` | No `tenant` claim (anonymous) | | User | `user:{NameIdentifier claim}` | Not authenticated | | IP | `ip:{RemoteIpAddress}` | No resolvable remote IP | Health probe paths (`/health`, `/healthz`, `/ready`, `/live`) are exempt from all three, so aggressive orchestrator probes never get throttled. The kit's health endpoints also call `.DisableRateLimiting()` on their route group. The tenant bucket is the SaaS guardrail: one noisy tenant burns its own 1000-requests-per-minute budget without starving the others. ## The auth policy The `auth` policy partitions by **user id when authenticated, otherwise by IP**, at 10 requests per minute. The Identity module attaches it with `.RequireRateLimiting("auth")`: | Endpoint | Why | |---|---| | `POST /api/v1/identity/token/issue` | Login attempts | | `POST /api/v1/identity/token/refresh` | Token refresh attempts | | `POST /api/v1/identity/forgot-password` | Password reset request abuse | | `POST /api/v1/identity/reset-password` | Password reset attempts | | `GET /api/v1/identity/confirm-email` | Email confirmation attempts | | `POST /api/v1/identity/users/{id}/resend-confirmation-email` | Confirmation mail flooding | | `POST /api/v1/identity/self-register` | Self-registration abuse | ```csharp group.MapGenerateTokenEndpoint().AllowAnonymous().RequireRateLimiting("auth"); ``` `AuthRateLimitWiringTests` (in `src/Tests/Integration.Tests/Tests/Authentication/`) verifies every endpoint that should carry the policy actually does. Run it after touching the Identity module's endpoint registrations. Reuse the same policy on your own sensitive anonymous endpoints — anything a bot can hammer without credentials is a candidate: ```csharp endpoints.MapPost("/api/v1/contact", handler) .AllowAnonymous() .RequireRateLimiting("auth"); ``` Additional named policies aren't config-driven — they're registered in code inside `AddHeroRateLimiting` (the Web building block, which is protected; see `buildingblocks-protection.md` before changing it). ## What a rejected request sees The limiter rejects with HTTP 429 and a `ProblemDetails` body (`application/problem+json`): ```http HTTP/1.1 429 Too Many Requests Retry-After: 42 Content-Type: application/problem+json { "type": "https://datatracker.ietf.org/doc/html/rfc6585#section-4", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded. Please retry later.", "instance": "/api/v1/identity/token/issue", "traceId": "4a7d8e1f2c...", "correlationId": "..." } ``` `Retry-After` (seconds) is included when the limiter can compute it; `traceId` and `correlationId` cross-reference server logs and traces. ## What rate limiting doesn't protect against - **Distributed brute force** — 10,000 IPs sending one request each per minute get through every per-IP limit. Combine with credential-stuffing detection, MFA, and account lockout (the kit ships lockout via ASP.NET Identity — 15-minute default lockout window). - **Slow-rate attacks** — an attacker sending 1 request per 10 seconds from one IP never hits a 10/min limit but might still be malicious. Combine with anomaly detection on auth-failure rates. - **Application-layer DDoS** — rate limiting helps but isn't a complete defence. Use a CDN / WAF in front (Cloudflare, AWS WAF, Azure Front Door) for layer-7 protection. ## Watching for false positives When a legitimate user hits the limit, they see 429 + `Retry-After`. In production, log every 429 with the source partition and endpoint: - A single IP hitting 429 repeatedly on login → likely brute force. - Many users hitting 429 on the same legitimate endpoint → your limit is too low. - One user hitting 429 on refresh → their client may be misconfigured (no token caching, retrying too aggressively). Set up an alert on "429 rate > X% of requests" so you catch tuning issues early. ## Related - [Security overview](/docs/security/) — broader auth + abuse protection. - [Quota building block](/docs/building-blocks/quota/) — per-tenant resource budgets (different concern). - [HTTP resilience](/docs/cross-cutting-concerns/http-resilience/) — caller-side back-off when receiving 429. --- # Realtime > SignalR with a Valkey backplane, group conventions for users + channels, post-SaveChanges broadcast pattern, and distributed-cache typing throttle. Source: https://fullstackhero.net/docs/cross-cutting-concerns/realtime/ The kit's realtime surface is a single SignalR hub — `AppHub` in `BuildingBlocks/Web/Realtime/`, mapped at `/api/v1/realtime/hub` — serving every realtime concern. On connect, every user joins their personal `user:{userId}` group, their `tenant:{tenantId}` group, plus every `channel:{channelId}` they belong to. Handlers broadcast via `IHubContext` after `SaveChanges` commits; the **Valkey** (a Redis-compatible, BSD-licensed Redis fork) backplane fans the broadcast across instances. A second hub would mean a second auth pipeline, a second backplane configuration, a second pile of client-side connection state. Keep all realtime traffic on `AppHub`; identify event types via the SignalR method name (`ChatMessageCreated`, `NotificationCreated`, `TicketResolved`). ## Opt in ```csharp builder.AddHeroPlatform(o => o.EnableRealtime = true); app.UseHeroPlatform(p => p.MapRealtime = true); ``` `AddHeroRealtime` registers SignalR; if `CachingOptions:Redis` is set, it also calls `AddStackExchangeRedis(...)` (channel prefix `fsh-signalr`) so cross-instance message fan-out works. Without Valkey, every instance broadcasts only to its own connected clients — fine for dev, not for multi-instance production. `MapRealtime` maps the hub at `/api/v1/realtime/hub` plus a presence snapshot endpoint at `GET /api/v1/realtime/presence?userIds=a,b,c` that clients poll for initial online status. ## Group conventions When a user's `HubConnection` lands at `/api/v1/realtime/hub`, the hub's `OnConnectedAsync` joins: 1. `user:{userId}` — for events targeted at this user (notifications, DM events, ticket-assigned). 2. `tenant:{tenantId}` — scopes tenant-wide broadcasts (like `PresenceChanged`) so one tenant's connect/disconnect churn never fans out to other tenants. 3. `channel:{channelId}` for every channel the user belongs to (the kit's `IUserChannelLookup` returns the list). The result: broadcasting to `Clients.Group($"user:{userId}")` reaches every device that user is signed in on; broadcasting to `Clients.Group($"channel:{channelId}")` reaches every member of the channel. Connect-time joins only cover channels that existed at connect time. A channel created — or a membership granted — *after* the socket is live is covered by the `JoinChannel(channelId)` hub method: clients call it when they open a conversation and on reconnect. It's membership-checked and idempotent, so calling it freely is safe. For domain-specific groups (e.g. "everyone watching this dashboard"), invent a stable group name (`dashboard:{tenantId}:{dashboardId}`) and call `Groups.AddToGroupAsync` from a hub method or post-connect logic. ## Broadcasting from a handler The kit's pattern is "save changes, then broadcast": ```csharp public sealed class SendMessageCommandHandler( IChatDbContext db, IHubContext hub, ICurrentUser current) : ICommandHandler { public async ValueTask Handle(SendMessageCommand cmd, CancellationToken ct) { var message = Message.Create(/* ... */); db.Messages.Add(message); await db.SaveChangesAsync(ct).ConfigureAwait(false); // first commit, then broadcast var dto = MessageDto.From(message); await hub.Clients.Group($"channel:{cmd.ChannelId}") .SendAsync("ChatMessageCreated", dto, ct).ConfigureAwait(false); return dto; } } ``` The order matters: if you broadcast before SaveChanges and the commit fails, you've leaked a phantom event to clients. Always commit first, broadcast second. ## Typing indicator with throttle Chat-style typing indicators flood the wire if you broadcast every keystroke. The kit's pattern is a **3-second distributed-cache throttle** per (channel, user): ```csharp // AppHub.cs (simplified) public async Task Typing(Guid channelId) { var userId = GetUserId(); if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false)) return; var key = $"typing:{channelId}:{userId}"; if (!string.IsNullOrEmpty(await _cache.GetStringAsync(key, Context.ConnectionAborted).ConfigureAwait(false))) return; // throttled await _cache.SetStringAsync(key, "1", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) }, Context.ConnectionAborted).ConfigureAwait(false); await Clients.OthersInGroup($"channel:{channelId}") .SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted) .ConfigureAwait(false); } ``` The `IDistributedCache` marker key expires after 3 seconds, so the broadcast fires at most once per (channel, user) per window. With Valkey, the throttle is cluster-wide — chatty clients can't bypass it by reconnecting to a different node. Note `OthersInGroup` — the typist doesn't need their own indicator. ## Authenticating SignalR connections Browsers' native `WebSocket` API doesn't accept `Authorization` headers on the initial handshake. SignalR's JS client routes around this by accepting an `accessTokenFactory`: ```ts const conn = new HubConnectionBuilder() .withUrl('/api/v1/realtime/hub', { accessTokenFactory: () => getAccessToken() }) .withAutomaticReconnect() .build(); ``` The factory returns the JWT; the SignalR client appends it as `?access_token=...`. The kit's `ConfigureJwtBearerOptions` has an `OnMessageReceived` handler that accepts `?access_token=` only on a narrow path allow-list — `/api/v1/realtime/hub` and `/notifications` — so query-string tokens can't leak into ordinary endpoints. The standard JWT pipeline validates the token from there. ## Reading the user from the hub `AppHub` reads identity from `Context.User`, not from a constructor-injected `ICurrentUser`. The reason: `ICurrentUser` depends on `IHttpContextAccessor`, and the originating `HttpContext` from the SignalR negotiate request is not pinned to subsequent hub-method invocations. Read claims directly (this is the hub's actual helper): ```csharp private string? GetUserId() { var user = Context.User; if (user?.Identity?.IsAuthenticated != true) return null; return user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub") ?? user.FindFirstValue("uid"); } ``` This is a memory-noted gotcha — `ICurrentUser` in a hub method returns the wrong identity (or null) once the connection has been alive for any length of time. Always go through `Context.User`. ## Integration testing SignalR `TestServer` (which the kit uses for integration tests) has **no WebSocket transport**. Tests must force long-polling on the connection: ```csharp var connection = new HubConnectionBuilder() .WithUrl($"http://localhost/api/v1/realtime/hub?access_token={Uri.EscapeDataString(accessToken)}", options => { options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); // TestServer has no WebSocket transport — force long-polling. options.WebSocketFactory = (_, _) => throw new NotSupportedException(); options.Transports = HttpTransportType.LongPolling; options.Headers["tenant"] = TestConstants.RootTenantId; }) .Build(); ``` The Chat integration tests (`src/Tests/Integration.Tests/Tests/Chat/` — `RealtimeEventsTests`, `TypingIndicatorTests`, `PresenceTests`) all carry this `ConnectAsync` helper. Use it as the template for any new realtime integration test. ## Configuration ```jsonc { "CachingOptions": { "Redis": "localhost:6379" // enables the SignalR backplane }, "CorsOptions": { "AllowAll": false, "AllowedOrigins": ["https://app.example.com"] } } ``` Two important config notes: - **CORS for credentialed requests.** `AllowAnyOrigin()` silently breaks SignalR while REST keeps working — the CORS spec forbids `*` with credentialed requests, and SignalR's negotiate always runs credentialed. The kit's `AddHeroCors` handles this: `AllowAll: true` uses `SetIsOriginAllowed(_ => true) + AllowCredentials()` (echoes the request origin instead of `*`); `AllowAll: false` uses an explicit origin allow-list with `AllowCredentials()`. Use the allow-list in prod. - **Sticky sessions / connection affinity.** Long-polling fallback needs sticky sessions on the load balancer; WebSockets don't. Configure both layers to be safe. ## Related - [Chat module](/docs/modules/chat/) — the canonical consumer of every realtime feature listed here. - [Notifications module](/docs/modules/notifications/) — `NotificationCreated` push. - [Server-Sent Events](/docs/cross-cutting-concerns/server-sent-events/) — one-way streaming when SignalR is overkill. - [Caching](/docs/cross-cutting-concerns/caching/) — the Valkey backplane is shared with the cache. --- # Server-Sent Events > Built-in SSE endpoints — token exchange + authenticated stream — with a connection manager for one-way pushes when SignalR is overkill. Source: https://fullstackhero.net/docs/cross-cutting-concerns/server-sent-events/ Server-Sent Events (SSE) are a small HTTP-streaming primitive: one connection, one direction, plain-text frames. The kit ships a complete SSE transport — a token-exchange endpoint, an authenticated stream endpoint, and a connection manager modules push events through — for the cases where SignalR is overkill: live feeds, usage gauges, status streams. **Use SSE** when the server pushes to the client and the client doesn't push back. Smaller surface, no client library required, works through most proxies, lower memory footprint.

**Use SignalR** when you need bidirectional messaging (typing indicators, presence, chat), automatic reconnection logic, or you already have it wired (the Chat + Notifications modules already use SignalR — adding SSE alongside is fine).
## Opt in ```csharp builder.AddHeroPlatform(o => o.EnableSse = true); // SseConnectionManager + token service app.UseHeroPlatform(p => p.MapSseEndpoints = true); // maps the two endpoints below ``` The shipped host enables both. ## The two endpoints | Endpoint | Auth | What it does | |---|---|---| | `POST /api/v1/sse/token` | JWT (standard `Authorization` header) | Issues a short-lived opaque stream token: `{ "token": "" }` | | `GET /api/v1/sse/stream?token=` | The token from above | Opens the event stream | Why the two-step dance: browsers' `EventSource` (and plain `fetch` streaming) can't attach an `Authorization` header the way XHR can, and putting a long-lived JWT in a URL leaks it into logs and proxies. So the client exchanges its JWT for a **single-use token that expires in 30 seconds**, then opens the stream with it. The token is stored in `IDistributedCache` (key `sse:tok:{guid:N}`), deleted on first consume, and carries only the user id + tenant id. An invalid or reused token gets a 401. The token is checked **only at the stream handshake** — once the stream is open, its lifetime depends on the network/server, not the token. On reconnect the client simply requests a fresh token. ## What the stream sends On connect, the endpoint sets `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and `X-Accel-Buffering: no` (disables nginx buffering), then **immediately flushes a `:connected` comment frame**. That eager flush matters: Kestrel buffers response headers until the first body write, and without it the client's `fetch()` promise would sit pending until the first heartbeat — up to 15 seconds of "connecting…" on every reconnect. After that the loop interleaves: - **Events** — written in standard SSE framing (`id:` when set, `event:`, `data:` per line, blank line), flushed per event. - **Heartbeats** — a `:heartbeat` comment every 15 seconds (fixed interval) so intermediaries (corporate proxies, load balancers) don't kill idle-looking connections. Two headers you might expect are deliberately absent: `Connection: keep-alive` is a hop-by-hop header that's forbidden on HTTP/2+, so Kestrel would strip it and warn on every connect. ## Pushing events from the backend Resolve the singleton `SseConnectionManager` and write `SseEvent` records: ```csharp public sealed record SseEvent(string EventType, string Data, string? Id = null); // targeted: every open tab/device of one user connectionManager.TrySend(userId, new SseEvent("usage-updated", json)); // tenant-wide connectionManager.Broadcast(tenantId, new SseEvent("maintenance-window", json)); // everyone (cross-tenant — use sparingly) connectionManager.BroadcastAll(new SseEvent("platform-notice", json)); ``` Each connection is backed by a bounded channel (100 events, `DropOldest`) — a slow client silently loses its oldest undelivered events instead of exerting backpressure on the producer. Per-instance only: the manager holds in-process connections, so in a multi-instance deployment an event raised on instance A doesn't reach a client connected to instance B. For cross-instance fan-out, use SignalR (which has the Valkey backplane) or publish through your own pub/sub before calling the manager. ## Client side The kit's dashboard (`clients/dashboard/src/sse/`) is the reference client. It uses `fetch` + a streaming reader rather than `EventSource`, so it can send the `tenant` header and drive its own reconnect/backoff loop: ```ts const { token } = await apiFetch<{ token: string }>("/api/v1/sse/token", { method: "POST" }); const response = await fetch(`${apiBase}/api/v1/sse/stream?token=${encodeURIComponent(token)}`, { headers: { Accept: "text/event-stream", tenant }, signal: controller.signal, }); const reader = response.body.getReader(); for await (const ev of parseSseStream(reader)) { // ... handle ev.event / ev.data } ``` When the stream drops, the loop requests a fresh token and reconnects with exponential backoff. Native `EventSource` also works if you don't need custom headers — point it at the stream URL with the token query parameter. ## What SSE doesn't do - **No bidirectional messaging.** Server → client only. If the client needs to send anything back, use a separate HTTP request or switch to SignalR. - **No native delivery guarantees.** Combined with the `DropOldest` channel, a slow or briefly-disconnected client can miss events. If you need catch-up, set `SseEvent.Id` and have the client send a "last seen" cursor on reconnect — that replay protocol is yours to build. - **No cross-instance fan-out** — see above. ## When to reach for SignalR instead - **Chat-like patterns** — bidirectional, presence, typing indicators, multi-channel. - **Need client-driven actions** — start / stop / pause / resume from the client. - **Multi-instance fan-out** — SignalR's Valkey backplane handles it; the SSE manager doesn't. - **You already have SignalR enabled** — each extra mechanism is a maintenance surface. Pick one per feature. ## Related - [Realtime (SignalR)](/docs/cross-cutting-concerns/realtime/) — bidirectional mode. - [Chat module](/docs/modules/chat/) — SignalR usage example. - [Web building block](/docs/building-blocks/web/) — `EnableSse` toggle, `SseConnectionManager`, `SseTokenService`. --- # Overview > Docker Compose, Dokploy, Terraform, and managed-service swaps for production. Source: https://fullstackhero.net/docs/deployment/ Ship the kit to a single host or to managed cloud — recipes for each common path. --- # Local Orchestration with .NET Aspire > What spins up when you run the AppHost, and the decisions behind the local topology — Postgres, Valkey, MinIO, the migrator, the API, and both React apps. Source: https://fullstackhero.net/docs/deployment/aspire/ One command brings up the entire stack — databases, cache, object storage, the API, and both React apps — wired together with service discovery, health gating, and a single dashboard: ```bash dotnet run --project src/Host/FSH.Starter.AppHost ``` The composition lives in `src/Host/FSH.Starter.AppHost/AppHost.cs`. This page documents what it starts and **why** it's wired the way it is. ## What spins up by default | Resource | Aspire name | What it is | Lifetime | |---|---|---|---| | PostgreSQL | `postgres` | Primary database server; hosts the `fsh-db` database | Persistent (data volume) | | pgAdmin | *(sidecar)* | Web UI on **:5050**, auto-discovers every database on the server | Persistent | | Valkey | `redis` | Distributed cache (HybridCache L2) + SignalR backplane + Data Protection key ring (**Valkey** — a Redis-compatible, BSD-licensed Redis fork; resource name stays `redis`) | Persistent (data volume) | | RedisInsight | `redis-insight` | Key browser on **:5540**, pre-connected to the Valkey instance for inspecting cache keys in dev | Persistent | | MinIO | `minio` | S3-compatible object storage, **:9000** (API) / **:9001** (console) | Persistent (data volume) | | MinIO init | `minio-init` | One-shot: creates the `fsh-uploads` bucket + download policy, then exits | Run-once | | DB migrator | `fsh-starter-db-migrator` | One-shot: applies migrations across the tenant catalog + every tenant's module DBs (`apply --seed`, so the root admin exists), then exits | Run-once | | Demo seeder | `fsh-starter-demo-seeder` | One-shot, **dev-only**: runs `seed-demo` after the migrator to provision the `acme`/`globex` demo tenants + users, then exits | Run-once | | API | `fsh-starter-api` | The ASP.NET Core API (`net10.0`) | Long-running | | Admin app | `fsh-starter-admin` | Operator React + Vite SPA on **:5173** | Long-running | | Dashboard app | `fsh-starter-dashboard` | Tenant React + Vite SPA on **:5174** (with SSE live feed) | Long-running | The `fsh-starter-*` resource names — and the Docker volume names — are derived from the AppHost's assembly name. A CLI-scaffolded app (say `Acme.Store`) gets `acme-store-api`, `acme-store-admin`, and so on, so two FSH-based apps on one machine never collide on container or volume names. The third-party infra (`postgres`, `redis`, `minio`) and the `fsh-db` database keep stable names. The Aspire dashboard opens automatically and shows every resource's state, logs, traces, and endpoints. The API's Scalar UI is at `/scalar`. The API waits for Postgres and Valkey to be healthy **and** for the one-shot jobs (`minio-init`, `fsh-starter-db-migrator`, `fsh-starter-demo-seeder`) to finish before it starts. So the API never boots against an unmigrated database or a missing bucket — no retry loops, no first-request 500s. ## Design decisions ### Persistent infra, run-once jobs Postgres, Valkey, and MinIO use `ContainerLifetime.Persistent` with named data volumes. Your data, pgAdmin layout, and uploaded files survive `dotnet run` restarts, so the inner loop stays fast. The migrator and MinIO bootstrap are **run-once** — they do their job and exit rather than linger as "unhealthy." ### MinIO is S3, so dev matches prod Object storage uses the same `Storage__Provider = "s3"` code path locally as in production — only `ServiceUrl` and `ForcePathStyle` differ. The Files module's presigned-URL upload flow (browser → storage directly, bytes never proxied through the API) is exercised end-to-end in dev. MinIO is configured to accept browser PUTs from the admin (`:5173`) and dashboard (`:5174`) origins via `MINIO_API_CORS_ALLOW_ORIGIN`. ### The migrator is the production deploy step too The `fsh-starter-db-migrator` resource runs the same `FSH.Starter.DbMigrator` project you run as an explicit step in production (published as the `fsh-db-migrator` image; `dotnet run --project ... -- apply`). The database is **never** migrated at API startup — locally or in the cloud. One mechanism, two contexts. ### Valkey for caching and realtime, with a RedisInsight browser The cache engine is **Valkey** (a Redis-compatible, BSD-licensed Redis fork), pulled as `valkey/valkey:9.1.0`. It speaks the Redis protocol (RESP), so the .NET client (`StackExchange.Redis`) and every `CachingOptions:Redis` config key are unchanged — and the resource keeps the name `redis` in code. It backs the HybridCache L2, the SignalR backplane, and the Data Protection key ring (Hangfire uses PostgreSQL storage). A **RedisInsight** container rides along, pre-registered against the Valkey instance via `RI_REDIS_*` environment variables, so you can inspect cache keys and TTLs in dev with no manual setup. ### Valkey is a plain container, on plain TCP Aspire 13.4's `AddRedis()` forces TLS-by-default in run mode (which breaks StackExchange.Redis negotiation through the Aspire proxy) and never materializes the container. The AppHost therefore adds Valkey via `AddContainer(...)` with a plain-TCP endpoint, and the API runs with `CachingOptions__EnableSsl = false`. Keep it that way. ### The Postgres image is unpinned `AddPostgres("postgres")` doesn't pin an image tag — it tracks whatever the Aspire hosting package defaults to (PostgreSQL 18 as of Aspire 13.4). When a package update jumps a Postgres **major** version, the persistent `*-postgres-data` volume written by the older major can't be read and the container exits. Recovery for local dev data: remove the volume and relaunch — the migrator + seeders rebuild everything. ### Frontends target the API's HTTPS endpoint Both React apps get `VITE_API_BASE_URL` pointing at the API's **https** endpoint, not http. The API uses `UseHttpsRedirection()`, so a call to the http endpoint 307-redirects to https — and browsers strip the `Authorization` header on cross-origin redirects (a different scheme/port is cross-origin per the Fetch spec). Hitting https directly preserves the bearer token on every request. The Vite apps run un-proxied on fixed ports (5173 / 5174) so HMR works and the origins line up with the MinIO CORS allow-list. ### The React apps are optional The `//#if (frontend)` directives around the two `AddJavaScriptApp(...)` calls are `dotnet new` template conditionals. Scaffold with the backend-only option and the AppHost omits both React apps entirely. ## Ports | Service | Port | |---|---| | API | 7030 (https) · 5030 (http) | | Admin app | 5173 | | Dashboard app | 5174 | | Postgres | 5432 | | pgAdmin | 5050 | | Valkey | 6379 (container) | | RedisInsight | 5540 | | MinIO | 9000 (API) · 9001 (console) | ## From local to cloud The local topology maps almost one-to-one onto AWS: Postgres → RDS, Valkey → ElastiCache, MinIO → S3, the API container → ECS Fargate, and the two React apps → S3 + CloudFront. See [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/). --- # Deploy to AWS with Terraform > End-to-end AWS deployment — prerequisites, bootstrapping the state backend, configuring an environment, and the one-command deploy that ships the API and both React apps. Source: https://fullstackhero.net/docs/deployment/aws-terraform/ The kit ships production-grade Terraform that stands up the whole stack on AWS and publishes the API and both React apps with **one command**. This guide takes you from nothing installed to a running deployment. The Terraform lives under `deploy/terraform/`. ## What gets provisioned ``` ┌─────────────── CloudFront (OAC) ──────────────┐ Browser ──▶ admin SPA ─┤ private S3 │ ──▶ dashboard ─┘ private S3 │ │ Browser/API ──▶ CloudFront ──▶ ALB (+ WAF) ──▶ ECS Fargate: fsh-api ──▶ RDS PostgreSQL (HTTPS, opt) │ └─▶ ElastiCache Redis ├──────────────▶ S3 (file uploads, + CloudFront) │ one-shot ECS task: fsh-db-migrator ─────────┴──────────────▶ RDS (migrate + seed) ``` - **Network** — VPC across your AZs, public + private subnets, NAT gateway(s), and VPC endpoints (S3, ECR, Logs, Secrets Manager) to keep traffic off the public internet. - **API** — ECS Fargate service behind an Application Load Balancer, fronted by **AWS WAF** (rate limiting + managed rule sets). Autoscaling, deployment circuit breaker, container in private subnets. Optionally fronted by **CloudFront** (`enable_api_cloudfront`) so the HTTPS SPAs can call the API over HTTPS with no custom domain (no mixed-content). - **Migrations** — the schema is applied by a **one-shot `fsh-db-migrator` ECS task** the deploy runs after `apply` (never at API startup). It migrates and — in dev — seeds the admin user and default tenant; demo tenants are opt-in. - **Data** — RDS PostgreSQL (optional Multi-AZ, AWS-managed master password in Secrets Manager) and ElastiCache Redis/Valkey (encryption in transit + at rest). - **Files** — an S3 bucket for application uploads, optionally fronted by CloudFront. - **Frontends** — the admin and dashboard React SPAs each on a **private S3 bucket + CloudFront** (Origin Access Control), with SPA routing, default-on security headers (HSTS, nosniff, frame, referrer), and a Terraform-managed `config.json`. - **Observability** — CloudWatch alarms (opt-in) for ECS, RDS, Redis, and the ALB. Out of the box the SPAs and API are reachable on their AWS-assigned hostnames — CloudFront `*.cloudfront.net` for the SPAs (and the API too, when `enable_api_cloudfront` is set), or the ALB DNS name otherwise. Custom domains are optional — the per-app `*_cloudfront_aliases` / `*_certificate_arn` variables are present but commented out in the example tfvars. Bring your own ACM cert (in `us-east-1` for CloudFront) and DNS when you're ready. ## Prerequisites ### AWS - An **AWS account** and an IAM principal (user or SSO role) with permissions to create the resources above. The simplest start is an admin-level role; scope it down later. - **AWS CLI v2**, configured: `aws configure` (or `aws configure sso`). Verify with `aws sts get-caller-identity`. - A **container registry** the ECS tasks can pull from. Two images are used — the API (`ghcr.io/fullstackhero/fsh-api`) and the migrator (`ghcr.io/fullstackhero/fsh-db-migrator`), built together from the same commit and sharing one immutable tag. The defaults are the public GHCR images; point `container_registry` at your own (ECR/GHCR) if you publish your own builds. ### Tooling | Tool | Version | Needed for | |---|---|---| | Terraform | **>= 1.15.4** | everything | | AWS CLI | v2 | auth, S3 sync, CloudFront invalidation, the migrator ECS task | | jq | any | reading Terraform outputs in **`deploy.sh`** (the PowerShell `deploy.ps1` uses `ConvertFrom-Json` — no jq) | | Node | 20+ | building the React apps | | .NET SDK | 10 | only with `--build-api` (building/pushing the API + migrator images) | The configuration pins `required_version >= 1.15.4` and the AWS provider to `~> 6.46`. An older CLI will be rejected at `terraform init`. Upgrade with e.g. `choco upgrade terraform` (Windows), `brew upgrade terraform` (macOS), or your package manager. A committed `.terraform.lock.hcl` keeps provider versions identical across machines and CI. Verify: ```bash terraform version # >= 1.15.4 aws sts get-caller-identity jq --version node --version # >= 20 ``` ## Step 1 — Bootstrap the state backend (one-time) Terraform stores its state in an S3 bucket with native locking (no DynamoDB needed). Create it once per account: ```bash cd deploy/terraform/bootstrap terraform init terraform apply -var bucket_name=my-org-fsh-tfstate ``` This creates a versioned, encrypted, TLS-only S3 bucket with `prevent_destroy`. Put its name into each environment's backend config at `deploy/terraform/apps/starter/envs///backend.hcl`: ```hcl title="envs/dev/us-east-1/backend.hcl" bucket = "my-org-fsh-tfstate" key = "dev/us-east-1/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true ``` ## Step 2 — Configure your environment Each environment is a `*.tfvars` file under `deploy/terraform/apps/starter/envs///`. Edit the values for your account — at minimum the **globally unique** S3 bucket names: ```hcl title="envs/dev/us-east-1/terraform.tfvars" environment = "dev" region = "us-east-1" # S3 buckets — must be globally unique across all of AWS app_s3_bucket_name = "my-org-dev-fsh-app" dashboard_s3_bucket_name = "my-org-dev-fsh-dashboard" admin_s3_bucket_name = "my-org-dev-fsh-admin" # HTTPS for the API with no custom domain: front the ALB with CloudFront so the # HTTPS SPAs can call it without mixed-content errors. enable_api_cloudfront = true db_name = "fshdb" db_username = "fshadmin" db_manage_master_user_password = true # AWS manages the password in Secrets Manager # Container images — the API and migrator share one immutable tag (never "latest"). # CI publishes "dev-"; `deploy.sh --build-api` publishes a 12-char short SHA. container_registry = "ghcr.io/fullstackhero" api_image_name = "fsh-api" migrator_image_name = "fsh-db-migrator" container_image_tag = "dev-" # What the one-shot migrator task runs. Dev migrates AND seeds (admin + default # tenant); seeding is idempotent. Demo tenants (acme/globex) are opt-in via --seed-demo. migrator_command = ["apply", "--seed"] ``` `dev`, `staging`, and `prod` ship as ready-to-edit examples (prod enables Multi-AZ, deletion protection, autoscaling, and CloudWatch alarms). `dev` ships for **two** regions — `us-east-1` and `ap-south-1` — as a multi-region template. ## Step 3 — Deploy (one command) From `deploy/terraform/apps/starter/`. The **region is required** — pass it as the second argument (the scripts never assume one; they prompt when it is omitted interactively and fail in CI): ```bash title="bash / CI / macOS / Linux" ./deploy.sh dev us-east-1 ``` ```powershell title="Windows PowerShell" ./deploy.ps1 -Environment dev -Region us-east-1 ``` That single command runs, in order: 1. `terraform init` + `apply` for the env/region (all the infra above). 2. *(optional, `--build-api`)* builds and pushes **both** the API and migrator container images at the current git SHA and deploys that tag. 3. runs the **`fsh-db-migrator` one-shot ECS task** (`apply` / `apply --seed`) and waits for it to exit `0` — the schema is applied here, not at API startup. 4. `npm run build` for each React app, `aws s3 sync` to its bucket (keeping the Terraform-managed `config.json`), and a CloudFront invalidation. First deploy, building the images yourself, with no prompts: ```bash ./deploy.sh prod us-east-1 --build-api --auto-approve ``` ### Flags | `deploy.sh` | `deploy.ps1` | What | |---|---|---| | `--build-api` | `-BuildApi` | Build & push the API **and** migrator images at the current git SHA and deploy that tag (needs the .NET SDK + a registry login). | | `--image-tag TAG` | `-ImageTag TAG` | Deploy a specific, already-published tag. | | `--registry REG` | `-Registry REG` | Container registry (default `ghcr.io/fullstackhero`); only used with build. | | `--skip-migrate` | `-SkipMigrate` | Skip the migrator ECS task after apply. | | `--seed-demo` | `-SeedDemo` | After migrating, also run the migrator's `seed-demo` verb (acme/globex demo tenants). | | `--skip-frontend` | `-SkipFrontend` | Skip building/publishing the SPAs (infra + migrate only). | | `--auto-approve` | `-AutoApprove` | Don't prompt before `terraform apply`. | Each SPA's `config.json` (its `apiBase`, default tenant, etc.) is written by Terraform so the front ends always point at the right API. The deploy step syncs your build with `--exclude config.json` so it's never clobbered — one build artifact promotes across every environment unchanged. ### Database migrations & seeding The deploy never migrates at API startup. After `terraform apply` it runs the **`fsh-db-migrator` image as a one-shot Fargate task** in the private subnets, with the verb from `migrator_command` (dev: `["apply", "--seed"]`), and fails the deploy if the task exits non-zero (pointing you at its CloudWatch log group). Seeding is idempotent. The **acme/globex demo tenants** are opt-in — add `--seed-demo` / `-SeedDemo` to also run the migrator's `seed-demo` verb. Skip the whole step with `--skip-migrate` / `-SkipMigrate`. ### Prefer raw Terraform? ```bash cd deploy/terraform/apps/starter/app_stack terraform init -backend-config=../envs/dev/us-east-1/backend.hcl terraform apply -var-file=../envs/dev/us-east-1/terraform.tfvars ``` Then run the migrator task and publish each SPA by hand (see the app stack `README.md`) — or just use `deploy.sh` / `deploy.ps1`, which do all three. ## Step 4 — Find your endpoints ```bash cd deploy/terraform/apps/starter/app_stack terraform output api_url # HTTPS CloudFront URL when enable_api_cloudfront, else the ALB URL terraform output dashboard_site # { bucket_name, cloudfront_domain_name, url, ... } terraform output admin_site ``` Open the CloudFront `url` from each site output to reach the apps. The API's CORS allow-list is wired automatically to include both SPA origins. ## Layout reference ``` deploy/terraform/ ├── bootstrap/ # one-time S3 state backend ├── modules/ # reusable building blocks │ ├── network/ alb/ waf/ ecs_cluster/ ecs_service/ │ ├── rds_postgres/ elasticache_redis/ s3_bucket/ │ ├── ecs_task/ # the one-shot DbMigrator task │ ├── static_site/ # private S3 + CloudFront for an SPA │ └── cloudwatch_alarms/ └── apps/starter/ ├── app_stack/ # the root config — run Terraform here ├── envs/// # backend.hcl + terraform.tfvars per environment ├── deploy.sh / deploy.ps1 # one-command deploy (infra → migrate → publish SPAs) └── destroy.ps1 # tear one env/region down ``` ## Security defaults - API tasks run in **private subnets** with no public IP; only the ALB is internet-facing, and it sits behind **WAF**. - The ALB drops invalid headers and runs desync mitigation in `defensive` mode. - RDS and Redis security groups are scoped to the VPC CIDR; Redis uses encryption in transit + at rest; the DB password is managed in **Secrets Manager** and injected via the ECS task execution role. - SPA buckets are **private** — CloudFront reads them through Origin Access Control, and a managed response-headers policy adds HSTS and the usual hardening headers. - The API container runs as a **non-root** user (`1654`) on the .NET `noble` runtime base image. ## Teardown The `destroy.ps1` helper re-points the backend and destroys one env/region in one step: ```powershell title="Windows PowerShell" ./destroy.ps1 -Environment dev -Region us-east-1 ``` ```bash title="raw Terraform (bash)" cd deploy/terraform/apps/starter/app_stack terraform init -reconfigure -backend-config=../envs/dev/us-east-1/backend.hcl terraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvars ``` `dev` sets `force_destroy` on buckets and skips the RDS/Redis final snapshot, so it tears down cleanly and repeatably. `staging`/`prod` enable RDS deletion protection and ALB deletion protection — you must disable those (and empty the buckets) before a destroy will succeed. Tearing down the app stack does **not** remove the GHCR images or the Terraform state bucket from Step 1 (which has `prevent_destroy`). ## Next - [Local Orchestration with Aspire](/docs/deployment/aspire/) — the dev-time mirror of this topology. --- # CI/CD Pipeline > The GitHub Actions setup — path-scoped backend and frontend pipelines, a pinned SDK, deduplicated test + coverage, CodeQL, the template smoke guard, and how container images and NuGet packages are published. Source: https://fullstackhero.net/docs/deployment/ci-cd/ The kit ships **four** GitHub Actions workflows in `.github/workflows/`: | Workflow | Purpose | |---|---| | **`backend.yml`** | Builds, tests, and publishes the .NET backend (API + DbMigrator containers, CLI + template NuGet packages). Runs only when backend code changes. | | **`frontend.yml`** | Lints, builds, and E2E-tests the two React apps. Runs only when `clients/**` changes. | | **`codeql.yml`** | CodeQL security analysis on backend PRs + a weekly scan. | | **`template-smoke.yml`** | Guards the distribution path: scaffolding from the `dotnet new` template must always build. | Two design choices shape everything below: - **Path-scoped pipelines.** A change under `clients/**` never builds or tests the API, and a change under `src/**` never builds the React apps. You only pay for — and wait on — the checks relevant to what you touched. - **A pinned SDK.** A root `global.json` pins the .NET 10 **GA** SDK (`rollForward: latestFeature`), and every workflow resolves it with `setup-dotnet`'s `global-json-file`. CI is deterministic and never silently pulls a preview SDK. (`global.json` is excluded from the `dotnet new` template, so scaffolded projects aren't pinned to the kit's SDK band.) The repo uses a single long-lived branch, **`main`** (the default). Workflows trigger on pushes and PRs to `main` plus `v*` tags — there is no `develop` branch. See [Contributing](/docs/contributing/) for the branching model. ## backend.yml — build, test, publish Triggers on pushes to `main`, `v*` tags, and PRs to `main`, plus manual `workflow_dispatch` (with an optional `version` input). PR runs cancel in-progress duplicates. | Job | Depends on | Runs when | What it does | |---|---|---|---| | **Detect changes** | — | always | A `dorny/paths-filter` step decides whether backend code changed. On any push/tag/dispatch it's always `true`; on a PR it's `true` only when `src/**`, `global.json`, `coverage.runsettings`, or the workflow itself changed. Every heavy job gates on this. | | **Unit Tests** | Detect changes | backend changed | `dotnet build -c Release -warnaserror` (the build gate), an audit that **fails on directly-referenced vulnerable packages** (transitive advisories are reported, not blocking), then the 12 unit/architecture test projects **with coverage collection**. Uploads the coverage as an artifact. | | **Integration Tests** | Detect changes | backend changed | `Integration.Tests` + `Integration.Middleware.Tests` via `WebApplicationFactory` + Testcontainers (Docker on the runner), **with coverage collection**. Uploads its coverage as an artifact. | | **DbMigrator Container Smoke** | Detect changes | backend changed | Publishes the `fsh-db-migrator` image (Dockerfile-less SDK publish) and runs `apply --catalog-only` against an ephemeral Postgres 17 — asserts it finishes successfully. Catches container-publish and DI-graph regressions. | | **Coverage Gate** | Unit + Integration | backend changed | **Merges** the coverage artifacts from the two test jobs with ReportGenerator and **fails if line coverage drops below 80%**. It does *not* re-run the tests — a ratchet; raise `MIN_LINE` as coverage grows. | | **Publish Dev Containers** | Unit + Integration | push to `main` | Publishes the API **and** DbMigrator images to GHCR tagged `dev-` and `dev-latest`. | | **Publish Release** | Unit + Integration | `v*` tag, or `workflow_dispatch` on `main` | Packs the `fsh` CLI + the `dotnet new` template, pushes both to NuGet.org, and publishes the API + DbMigrator images to GHCR tagged `` and `latest`. | | **Backend CI** | the test, smoke, and coverage jobs | always | The single required status check. Always runs (even when the heavy jobs are skipped on a client-only PR) and fails only if a job it depends on actually **failed or was cancelled** — skipped is fine. Publish jobs are deliberately not gated on. | Each test job collects coverage as it runs, and the **Coverage Gate** downloads both artifacts and merges them. Earlier the gate re-ran the whole solution on top of the dedicated test jobs, so every push tested the code twice. Merging keeps the same 80% floor while running each test exactly once. The migrator smoke job publishes the **same** `fsh-db-migrator` image you run in production and applies migrations against a throwaway Postgres. That's why the migrator is the recommended production deploy step — see [Database Migrations](/docs/deployment/database-migrations/). ### Required status checks and the gate jobs Because the pipelines are path-scoped, a client-only PR never starts the backend jobs — so a branch-protection rule that required, say, "Unit Tests" directly would block forever waiting on a check that never reports. To avoid that, each workflow ends with a small **gate job** — `Backend CI` and `Frontend CI` — that *always* runs and turns green when its side is skipped. **Require only `Backend CI` and `Frontend CI`** in your branch ruleset on `main` — not the individual jobs. They resolve correctly for backend-only, frontend-only, and cross-cutting PRs alike. ### Releasing A release publishes exactly two **NuGet packages** — the `fsh` CLI tool and the `dotnet new` template — plus the **API and DbMigrator container images**. The distribution model is source-ownership: consumers get the full source via the template, not per-module/BuildingBlocks packages. Two ways to trigger it: ```bash # Tag-driven (recommended): the tag name is the version git tag v10.0.0 && git push origin v10.0.0 ``` …or run the **Backend CI** workflow manually from the Actions tab on `main` with a `version` input (e.g. `10.0.1`). `GITHUB_TOKEN` (auto-provided) publishes container images to GHCR. Releases also need a `NUGET_API_KEY` repository secret to push packages to NuGet.org. ## frontend.yml — lint, build, E2E Triggers on pushes and PRs to `main` (and manual dispatch). A `Detect changes` job gates the work on `clients/**`. Both apps run as a matrix (`admin`, `dashboard`) on **Node 22** with npm caching. | Job | Runs when | What it does | |---|---|---| | **Detect changes** | always | Sets `frontend = true` on any push/dispatch, or on a PR when `clients/**` (or the workflow) changed. | | **Lint & Build** *(matrix)* | frontend changed | `npm ci`, `npm run lint` (ESLint), then `npm run build` — which is `tsc -b && vite build`, so it's the typecheck **and** the bundle gate. | | **E2E** *(matrix)* | frontend changed | `npm ci`, installs the Playwright Chromium browser, then `npm run test:e2e`. The Playwright config boots its own Vite dev server and mocks all API calls via `page.route()`, so **no backend is required**. Uploads the Playwright HTML report on failure. | | **Frontend CI** | always | The single required status check for the frontend side (same always-green-when-skipped behaviour as `Backend CI`). | ## codeql.yml — security analysis Runs CodeQL (`csharp`) on pull requests to `main` scoped to `src/**`, plus a scheduled weekly scan. Uses the pinned SDK via `global.json`. ## template-smoke.yml — distribution guard Runs on changes to `.template.config/**`, `templates/**`, `clients/**`, or `src/**`. It proves a freshly scaffolded project actually builds: - **Scaffold (Aspire + React)** — packs the template, installs the `.nupkg`, runs `dotnet new fsh ... --aspire true --frontend true`, builds the backend (`-warnaserror`), builds **both** React apps (`npm ci && npm run build` on Node 22), and runs the Architecture tests on the scaffolded output. - **Scaffold (minimal)** — scaffolds backend-only (`--aspire false --frontend false`) and builds it, guarding the `//#if (frontend)` / `(!aspire)` conditional gating in `AppHost.cs` and the solution. ## How CI feeds deployment The image CI publishes is what you deploy. Reference it by its immutable tag in your environment's `terraform.tfvars`: ```hcl container_image_tag = "v10.0.0" # or a dev- tag from a main build ``` Then deploy — see [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/). For a fully automated path, run the one-command deploy from your own deploy workflow after the image is published. ## Local parity Every backend CI check has a local equivalent, so you can reproduce a red build before pushing: ```bash dotnet build src/FSH.Starter.slnx -c Release -warnaserror # Unit Tests build gate dotnet test src/FSH.Starter.slnx -c Release # Unit + Integration (needs Docker) ``` For the frontend, in each of `clients/admin` and `clients/dashboard`: ```bash npm ci && npm run lint && npm run build # Lint & Build npm run test:e2e # E2E (route-mocked; boots Vite itself) ``` --- # Database Migrations (DbMigrator) > The FSH DbMigrator — the one-shot tool that applies EF Core migrations across the tenant catalog and every tenant's per-module databases. The database is never migrated at API startup. Source: https://fullstackhero.net/docs/deployment/database-migrations/ `FSH.Starter.DbMigrator` is a standalone console app that applies EF Core migrations. It is the **single, authoritative way** the database schema is created and evolved — locally and in production. The API does **not** run migrations when it boots. Schema changes are always an explicit, observable step you run on purpose. This avoids racing instances all trying to migrate at once, gives you a clear go/no-go before traffic shifts, and keeps a multi-tenant migration (which touches many databases) out of the request path. ## What it does Each run migrates, in order: 1. **The tenant catalog** — the control-plane database that lists tenants. 2. **Every tenant's per-module databases** — one pass per tenant, applying each module's migrations. Migrations themselves live in `src/Host/FSH.Starter.Migrations.PostgreSQL`, organized per module by folder. ## Commands ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- [verb] [options] ``` | Verb | What it does | |---|---| | `apply` *(default)* | Apply pending migrations. Add `--seed` to also run the per-tenant seed. | | `seed` | Run only the seed step per tenant (no migration). | | `seed-demo` | Provision demo tenants (`acme`, `globex`) with users, catalog, tickets, and chat. **Dev-only** — refuses to run unless `DOTNET_ENVIRONMENT=Development` (the migrator is a generic-host console app, so it reads `DOTNET_ENVIRONMENT`, **not** `ASPNETCORE_ENVIRONMENT`). Under Aspire this runs automatically — see [Local development](#local-development). | | `list-pending` | Print pending migrations without applying anything. | | Option | Effect | |---|---| | `--tenant ` | Restrict to a single tenant id (default: all tenants). | | `--catalog-only` | Migrate only the tenant catalog; skip the per-tenant pass. | | `--seed` | After `apply`, also call `SeedTenantAsync` per tenant. | | `-h`, `--help` | Print help. | **Exit codes:** `0` success · `1` failure (the exception is logged). The non-zero code is what your CI/CD pipeline should gate the deploy on. ```bash # preview, apply, apply+seed, scope to one tenant, catalog only dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --tenant acme dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --catalog-only ``` ## Local development When you run the stack via Aspire, the migrator runs **for you**: the `fsh-starter-db-migrator` resource executes `apply --seed` on each AppHost launch (so the root admin `admin@root.com` is seeded), then a dev-only `fsh-starter-demo-seeder` resource runs `seed-demo` to provision the `acme`/`globex` demo tenants and their users. The API is gated on both (`WaitForCompletion`), so it never starts against an unmigrated database and the demo logins work the moment the dashboard loads. See [Local Orchestration with Aspire](/docs/deployment/aspire/). You can also run it directly any time: ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed ``` To populate demo tenants for a local demo or test drive (Aspire already does this for you on launch; run it manually only when you're **not** using Aspire — set `DOTNET_ENVIRONMENT=Development` so the dev-only verb proceeds): ```bash DOTNET_ENVIRONMENT=Development dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo ``` ## Production In production the migrator is the **explicit deploy step that runs before the new API serves traffic**. It's published as its own container image (`fsh-db-migrator`) so you can run it as a one-off task right next to the API. RDS lives in private subnets, so the migrator has to run somewhere that can reach it — typically a one-off **ECS Fargate task** using the `fsh-db-migrator` image in the same private subnets and security group context as the API. A laptop or a CI runner outside the VPC can't connect to a private RDS instance. A typical pipeline order: 1. Build & push the new `fsh-api` and `fsh-db-migrator` images (same tag). 2. `terraform apply` to provision/update infra. 3. Run the migrator as a one-off ECS task: `aws ecs run-task` with the `fsh-db-migrator` image, command `apply`, in the private subnets. Wait for it to exit `0`. 4. Roll out the new API task definition. The [one-command AWS deploy](/docs/deployment/aws-terraform/) provisions infra and ships the API + SPAs, but it does **not** run the migrator (that needs in-VPC access to RDS). Run the migrator task as the step between `terraform apply` and shifting traffic — wire it into your CI/CD or run it as a one-off ECS task. ## Adding a module or a migration - **New migration** — generate it against `FSH.Starter.Migrations.PostgreSQL` (see the database rules / `create-migration` recipe), build, then `list-pending` to confirm and `apply`. - **New module** — the migrator has its **own** module wiring. Registering a module touches `DbMigrator/Program.cs` as well as the API's `Program.cs`; miss it and the module's migrations silently never run. See [Architecture](/docs/architecture/) for the full "four places" checklist. `list-pending` is free and side-effect-free. Run it in CI on every PR to catch un-applied or accidentally-removed migrations before they reach an environment. ## Next - [Local Orchestration with Aspire](/docs/deployment/aspire/) — how the migrator is wired into the dev loop. - [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/) — where the migrator step fits in a cloud rollout. --- # Overview > Two React + Vite + TypeScript apps ship with the kit — an admin console for operators and a tenant dashboard for end users. Source: https://fullstackhero.net/docs/frontend/ Two React + Vite + TypeScript apps ship in `clients/` — both production-grade, both wired against the API's auth + multitenancy + realtime out of the box. Pick the right one for your audience: **admin** is for operators (back-office, support, ops); **dashboard** is for end users (the people who pay you). Both apps follow the same conventions for auth, API integration, route guards, and realtime. They don't share a component library — the admin's audit log + impersonation pages don't belong in the user-facing dashboard, and the dashboard's chat / catalog pages don't belong in admin. Sharing patterns + diverging on features is the right axis. ## The two apps ## Quick facts | | **clients/admin** | **clients/dashboard** | |---|---|---| | Audience | Operators, support, ops | End users (tenant members) | | Default port | 5173 | 5174 | | Pages | Users, Roles, Tenants, Impersonation, Audits, Webhooks, Billing (plans + invoices), Health, Notifications, Settings | Overview, Activity, Catalog, Chat, Files, Tickets, Invoices, Subscription, Identity (users / roles / groups), System (sessions, trash, audits, health), Settings | | Realtime | SignalR notifications | SignalR (chat + notifications + presence) + an SSE activity feed | | Auth | Same JWT bearer + refresh flow as the API | Same | | Theming | Kit palette + per-operator light/dark | Per-user appearance (mode, accent, font, density); per-tenant `TenantTheme` lives in the Multitenancy module | | Inactivity sign-out | 10 min idle + 60 s warning (config.json) | 20 min idle + 60 s warning (config.json) | Both run via `npm run dev` (Vite dev server with HMR) and build to static assets via `npm run build`. The kit's Aspire AppHost orchestrates both alongside the API for one-command local dev. Both read runtime configuration from `/config.json` at boot — no `VITE_*` build-time env baked into the bundle — so a single build artifact promotes across environments. ## Related - [Admin console](/docs/frontend/admin/) — operator-facing app. - [Tenant dashboard](/docs/frontend/dashboard/) — end-user app. - [Frontend architecture](/docs/frontend/architecture/) — shared patterns. - [Theming](/docs/frontend/theming/) — per-tenant brand customisation. - [Local development](/docs/frontend/development/) — running, building, deploying. --- # Admin console > The operator-facing React + Vite app at clients/admin — users, roles, tenants, impersonation, audits, billing, webhooks, health, notifications. Source: https://fullstackhero.net/docs/frontend/admin/ `clients/admin` is the operator-facing app. It's where support engineers reset passwords, ops sees the audit trail, billing reviews tenant invoices, and SuperAdmins start impersonation sessions. It's not customer-facing — it has no per-tenant theming; the chrome stays in the kit's own palette (operators still get a light/dark toggle). ## Tech stack | | Version | Used for | |---|---|---| | React | 19.x | UI framework | | Vite | 7.x | Dev server + build | | TypeScript | 5.x | Type safety | | TanStack Query | 5.x | Server-state cache + mutations | | React Router | 7.x | Routing + protected routes | | react-hook-form + zod | 7.x / 3.x | Forms + schema validation (admin only — the dashboard doesn't use them) | | @microsoft/signalr | 10.x | Realtime connection to the kit's hub | | Tailwind CSS | 4.x | Styling (shadcn-style, with CVA) | | Radix UI | 1.x–2.x | Headless primitives (Dialog, DropdownMenu, etc.) | | Lucide | 0.4xx | Icon set | | Playwright | 1.x | Route-mocked E2E tests (`npm run test:e2e`) | All pinned in `clients/admin/package.json`. ## Page inventory ``` clients/admin/src/pages/ ├── audits/ Audit trail query UI (filter, detail side sheet) ├── auth/ Forgot / reset password, email confirmation ├── billing/ Plans + invoices (list + detail) ├── health/ System health probes ├── impersonation/ Grant list — active + historical, revoke ├── notifications/ Inbox, mark-read, real-time push ├── roles/ Role CRUD + permission editor ├── settings/ Profile, security (2FA), sessions, appearance ├── tenants/ Tenant CRUD + branding editor + provisioning status ├── users/ User search / detail / impersonate action ├── webhooks/ Subscription management + delivery log ├── dashboard.tsx Operator landing page └── login.tsx Tenant + email + password sign-in ``` The route definitions live in `clients/admin/src/routes.tsx`; pages beyond the shell + landing page are lazy-loaded so each route becomes its own bundle chunk. ## Authentication `clients/admin/src/auth/` owns the auth surface: - `auth-context.tsx` — React context providing the signed-in user, `login`, `logout`, and `refreshPermissions`. - `api.ts` — `issueToken()` wraps the login endpoint. - `token-store.ts` — wraps localStorage for access + refresh tokens and the active tenant. - `jwt.ts` — base64url decode + claim parsing (no library needed for read-only access). - `protected-route.tsx` — route guard that requires authentication; redirects to `/login` if not. - `route-guard.tsx` — permission-aware wrapper; renders `` if the user lacks the required permissions. - `use-auth.ts` — hook for accessing the context from components. - `inactivity.ts` + `use-inactivity-timeout.ts` — idle auto sign-out (see below). Login flow: ```ts const { login } = useAuth(); await login({ email, password, tenant }); // Calls POST /api/v1/identity/token/issue with the `tenant` header (and an // X-FSH-App: admin header so the API can enforce the operator-app boundary), // stores both tokens, then navigates to the post-login destination. ``` Token refresh is **reactive, not timer-based**: when any API call returns 401, the API client runs a single-flight refresh (`POST /api/v1/identity/token/refresh`) and retries the original request. At boot, if the stored access token is missing or expired but a refresh token exists, the auth context attempts one silent refresh before rendering protected routes. If a refresh fails (refresh token expired / revoked), the user is signed out and redirected to `/login`. ### Inactivity auto sign-out The admin console signs operators out after **10 minutes** of idle time, preceded by a **60-second** warning countdown. Both durations come from `/config.json` (`inactivityIdleMs`, `inactivityWarningMs`), and the last-activity timestamp is shared across tabs via a localStorage key (`fsh.lastActivity`) so one active tab keeps every tab alive. ## Permission-gated UI Endpoints are gated server-side by `.RequirePermission()`. The admin console mirrors that gating client-side so users don't see actions they can't perform: ```tsx import { useAuth } from '@/auth/use-auth'; const { user } = useAuth(); const can = (perm: string) => user?.permissions.includes(perm) ?? false; return ( <> {can(IdentityPermissions.Users.Update) && } {can(IdentityPermissions.Impersonation.Start) && } ); ``` The JWT only carries role names — effective permissions are resolved server-side and fetched from `GET /api/v1/identity/permissions` after sign-in, then cached on the auth context. If the user gains a permission server-side (an admin updates their role), `refreshPermissions()` picks it up without a re-login. The permission string constants live in `clients/admin/src/lib/permissions.ts`, mirroring the server registries. For whole-route gating, wrap the route's element in ``: ```tsx { path: "impersonation", element: ( ), } ``` Unauthorised users see a `` instead of the route content. While the permission fetch is still in flight, the guard renders a quiet loading state rather than flashing "access denied". ## API integration `clients/admin/src/api/` carries one file per backend module. Each file exposes typed functions wrapping the relevant endpoints with `apiFetch` — the kit's hand-written `fetch` wrapper (no codegen, no Axios): ```ts // clients/admin/src/api/impersonation.ts import { apiFetch } from "@/lib/api-client"; export function startImpersonation(input: StartImpersonationInput): Promise { return apiFetch("/api/v1/identity/impersonation/start", { method: "POST", body: JSON.stringify(input), }); } ``` Pages consume them via TanStack Query: ```tsx const startMut = useMutation({ mutationFn: startImpersonation, onSuccess: (response) => { /* open new tab with the impersonation token */ }, }); ``` `apiFetch` (`clients/admin/src/lib/api-client.ts`): - Adds the `Authorization: Bearer ...` header from the token store. - Adds the `tenant` header (the stored tenant, or the configured default) — callers can override it to scope a request to a specific tenant, which the admin's branding editor uses. - Handles 401 by running a **single-flight** token refresh and retrying the original request once; if refresh fails, the session is cleared. - Applies a 30-second default timeout via `AbortSignal`, merged with any caller-supplied signal. - Surfaces ProblemDetails responses as a typed `ApiRequestError` (status + title + detail + field `errors`) that React Query can render in the UI. ## Realtime — notifications `clients/admin/src/realtime/realtime-context.tsx` opens one shared SignalR connection to `/api/v1/realtime/hub` after sign-in (the SignalR client is dynamically imported so unauthenticated pages never download it). Components subscribe through a hook instead of touching the connection directly: ```tsx // clients/admin/src/components/notifications/notification-bell.tsx (simplified) useRealtimeEvent("NotificationCreated", (notification) => { queryClient.setQueryData(["notifications"], (prev) => prev ? [notification, ...prev] : [notification]); toast.info(notification.title); }); ``` The admin console only wires `NotificationCreated` today — the dashboard's variant of the same context carries the chat + presence traffic. A bell icon in the top bar shows unread count + a popover with the recent items. ## The impersonation flow in the UI This is the admin console's most-asked-about feature. Full walkthrough lives in the [operator impersonation guide](/docs/guides/operator-impersonation/); short version: 1. Find the user via **Users → search**. 2. Open the user detail page; click **Impersonate**. 3. Fill duration + reason; click **Start**. 4. A new tab opens at the tenant dashboard, authenticated as the impersonated user. The short-lived token is handed off via the URL hash — the admin app never installs it locally. 5. Use the dashboard exactly as the user would. 6. Return to admin; **revoke the grant** (from the Impersonation page or the inline active-grants card) to end the session server-side. The dashboard tab loses the session on its next API call and lands on a graceful "impersonation ended" page. ## Tenant branding editor The tenant detail page carries a branding card where root operators edit a tenant's `TenantTheme` — the light + dark colour palettes and brand asset URLs (logo, dark logo, favicon). Typography and layout fields exist on the server-side DTO but are intentionally left out of the v1 editor. The admin console itself stays themed in the kit's own palette (no per-tenant chrome for operator tools). See [Theming](/docs/frontend/theming/) for the full palette + asset surface. ## Notable pages ### Audits A full audit query UI with filters and a debounced search box; each row opens a detail side sheet with the captured payload (masked fields stay masked). ### Roles + permission editor Permission assignment per role, grouped by resource with per-action toggles and a bulk group toggle. ### Webhooks Subscription list with delivery log per subscription — every attempt, response code, error message. ## Local development ```bash cd clients/admin npm install npm run dev # http://localhost:5173 with HMR ``` The Vite dev server proxies `/api`, `/health`, `/openapi`, and `/scalar` to the backend — `http://localhost:5030` by default, overridable with a `VITE_API_BASE_URL` env var (this var only steers the **dev proxy target**; it is never baked into a build). Runtime configuration — API base URL, default tenant, inactivity timings — comes from `public/config.json`, fetched once at boot before React mounts. The kit's Aspire AppHost wires all of this automatically when you run `dotnet run --project src/Host/FSH.Starter.AppHost` — the API, admin, and dashboard all come up together. ## Build + deploy ```bash npm run build # tsc -b + vite build → dist/ contains static assets ``` The `dist/` folder is a static site — drop it on any static host (Nginx, Vercel, Netlify, S3+CloudFront). `clients/admin/Dockerfile` ships an Nginx-based container that serves the built assets and renders `/config.json` from environment variables (`FSH_API_URL`, `FSH_DEFAULT_TENANT`, `FSH_DASHBOARD_URL`) via `envsubst` at startup — one image, every environment. See [Local development](/docs/frontend/development/). ## Related - [Tenant dashboard](/docs/frontend/dashboard/) — the end-user-facing companion. - [Frontend architecture](/docs/frontend/architecture/) — shared patterns. - [Operator impersonation guide](/docs/guides/operator-impersonation/) — end-to-end UI walkthrough. - [Identity module](/docs/modules/identity/) — the API the admin console wraps. --- # Frontend architecture > Shared patterns across the admin + dashboard apps — folder layout, API client, route guards, realtime context, error handling, ProblemDetails parsing. Source: https://fullstackhero.net/docs/frontend/architecture/ Both `clients/admin` and `clients/dashboard` follow the same folder layout, the same auth model, the same API client conventions, and the same realtime patterns. They're separate apps because their audiences are separate, but the patterns shouldn't surprise you when you move between them. If you're forking only the dashboard (deleting admin), copy admin's audit-log viewer + role editor patterns into a tenant-admin-page in your dashboard if you ever need them. The patterns transfer; the audience separation doesn't have to. ## Folder layout ``` clients/{app}/ ├── package.json React 19, Vite 7, TS 5.x, TanStack Query, Radix, etc. ├── vite.config.ts Vite + React + Tailwind plugins + dev proxy config ├── tsconfig.json ├── index.html Single-page-app shell ├── playwright.config.ts Route-mocked E2E test config (tests/ alongside) ├── Dockerfile + docker/ Nginx runtime image + config.json template ├── public/ Static assets + config.json (runtime config) └── src/ ├── main.tsx Loads /config.json, then createRoot + ├── App.tsx Top-level provider stack + RouterProvider ├── routes.tsx createBrowserRouter config (lazy routes) ├── env.ts Runtime config loader + typed accessor ├── api/ One file per backend module — typed apiFetch wrappers ├── auth/ JWT, token store, auth context, route guards, inactivity ├── components/ Shared components (layout, primitives, theme) ├── hooks/ Shared hooks (e.g. use-file-upload) ├── lib/ Shared helpers — api-client, query-client, cn ├── pages/ One folder (or file) per feature area ├── realtime/ SignalR context + hooks └── styles/ Global CSS + Tailwind base ``` The dashboard adds `sse/` (Server-Sent Events context for the activity feed). ## The provider stack ```tsx // App.tsx (both apps) {/* admin: here; dashboard: a command-palette provider */} ``` Order matters: 1. Before any of this renders, `main.tsx` top-level-awaits `loadRuntimeConfig()` so `/config.json` is in memory before the first component reads `env.*`. 2. `ThemeProvider` outermost — light/dark/appearance applies even to the login screen. 3. `QueryClientProvider` so every child can use TanStack Query. 4. `AuthProvider` — contexts below depend on the token store + signed-in user. 5. Admin mounts its `RealtimeProvider` here; the dashboard mounts `RealtimeProvider` + `SseProvider` inside the authenticated `AppShell` instead, so public pages never open (or download) the realtime stack. 6. The router + `Toaster` come last. ## The API client `clients/{app}/src/lib/api-client.ts` exports `apiFetch()` — a hand-written wrapper over the browser's `fetch`. No Axios, no generated client; the typed request/response shapes live next to each module's functions in `src/api/`. ```ts // Simplified — the real thing is ~160 lines export async function apiFetch(path: string, init: RequestInitEx = {}): Promise { const headers = new Headers(init.headers); headers.set("Authorization", `Bearer ${tokenStore.getAccessToken()}`); headers.set("tenant", tokenStore.getTenant() ?? env.defaultTenant); let response = await fetch(`${env.apiBase}${path}`, { ...init, headers, signal }); if (response.status === 401 && tokenStore.getRefreshToken()) { await refreshAccessToken(); // single-flight — concurrent 401s share one refresh response = await fetch(/* retry original request with the fresh token */); } if (!response.ok) { const problem = await parseError(response); // ProblemDetails body, if JSON throw new ApiRequestError(response.status, problem?.title ?? response.statusText, problem); } return response.json() as Promise; } ``` What it handles for every call: - **Auth + tenant headers** from the token store (callers can override `tenant` per request, or pass `skipAuth` for anonymous endpoints like login). - **401 → single-flight refresh → retry.** Concurrent 401s share one in-flight refresh promise; if the refresh fails, tokens are cleared and the UI flips to `/login`. - **Timeouts** — a 30-second default via `AbortSignal.timeout`, merged with any caller-supplied signal. - **Typed errors** — non-2xx responses throw `ApiRequestError`, which carries the HTTP status plus the parsed ProblemDetails payload. React components can branch on it: ```tsx const { isError, error } = useQuery(...); if (isError && error instanceof ApiRequestError) { return ; } ``` For validation errors (400 with an `errors` field), render the field errors inline: ```tsx if (mutation.isError && mutation.error instanceof ApiRequestError) { const fieldErrors = mutation.error.problem?.errors ?? {}; // fieldErrors.Sku?: string[] — render next to the input } ``` ## TanStack Query conventions One file per backend module under `src/api/`, exporting typed functions: ```ts // clients/admin/src/api/users.ts (simplified excerpt) export async function searchUsers(params: SearchUsersParams = {}): Promise> { const qs = new URLSearchParams(/* ...params... */); return apiFetch>(`/api/v1/identity/users?${qs}`); } export async function getUser(id: string): Promise { return apiFetch(`/api/v1/identity/users/${id}`); } export async function registerUser(input: RegisterUserInput): Promise { return apiFetch(`/api/v1/identity/users/register`, { method: "POST", body: JSON.stringify(input), }); } ``` Pages consume via React Query: ```tsx const usersQuery = useQuery({ queryKey: ['users', { search, page }], queryFn: () => searchUsers({ search, page }), }); const createMut = useMutation({ mutationFn: registerUser, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }), }); ``` The `queryKey` is the canonical cache key. The kit uses arrays with one filter object per key entry — TanStack Query handles the hashing. For mutations that touch multiple resource caches (e.g. deleting a user that affects roles + groups), invalidate them all in `onSuccess`. TanStack Query reads the mutation options at **execute time**. If `onMutate` (or anything else) `setState`s a value that `mutationFn` closes over, the request can fire with the post-setState value — a race that bites exactly when you batch state updates before mutating. The kit's rule: everything a mutation needs travels as the argument to `mutate(arg)` / `mutationFn(arg)`; component state is never read inside the mutation callbacks. ## Route guards Two layers: ```tsx // Both apps: ProtectedRoute wraps the whole authenticated tree. // "Are you signed in?" — if not, redirect to /login. { element: , children: [{ element: , children: [/* all app routes */] }], } // Admin only: RouteGuard wraps individual route elements. // "Do you hold these permissions?" — if not, render . { path: "tenants", element: ( ), } ``` The guard mirrors the exact permission the server endpoint enforces — the constants in `src/lib/permissions.ts` track the server registries. The dashboard doesn't wrap routes; it gates navigation entries and in-page actions against the fetched permission list instead (e.g. the recycle-bin tabs + nav entry only render for users holding a restore/view-trash permission). Either way the server stays the real enforcement layer; the client gating is UX. ## Realtime context `src/realtime/realtime-context.tsx` (in both apps) owns one shared SignalR connection to `/api/v1/realtime/hub`. Components never see the raw `HubConnection` — the context exposes a small surface: ```tsx type RealtimeContextValue = { status: "idle" | "connecting" | "connected" | "reconnecting" | "error"; /** Wire a handler for a server event. Returns an unsubscribe function. */ on: (event: string, handler: (payload: T) => void) => () => void; /** Dashboard only: invoke a hub method, e.g. invoke("Typing", channelId). */ invoke: (method: string, ...args: unknown[]) => Promise; }; // Most components use the convenience hook: useRealtimeEvent("NotificationCreated", (n) => { /* ... */ }); ``` Implementation details worth knowing: - The `@microsoft/signalr` package is **dynamically imported** on first connect, so pages without a realtime consumer never download it (~37 KB gzip). - The provider subscribes to the token store — login, refresh-token rotation, and impersonation swaps all rebuild the connection with the fresh token. - Reconnects use SignalR's automatic-reconnect backoff plus an outer retry loop with jitter, capped at 60 s. - Transport is auto-negotiated (WebSockets → SSE → long-polling) for proxy-hostile networks. One connection per user session; every subscriber attaches via `on(event, handler)` and the returned function detaches it. The dashboard's chat page is the canonical user — see the [dashboard page](/docs/frontend/dashboard/#chat--the-realtime-showcase). ## Error handling The `ApiRequestError` shape (from `clients/{app}/src/lib/api-client.ts`): ```ts export class ApiRequestError extends Error { readonly status: number; // HTTP status readonly problem?: ApiError; // parsed ProblemDetails payload } export type ApiError = { status: number; title?: string; detail?: string; errors?: Record; // field validation errors [key: string]: unknown; }; ``` Components render errors via page-level error bands, inline field errors next to inputs, or a toast (sonner) for ephemeral failures that don't need persistent UI. ## Runtime configuration Neither app bakes environment values into the bundle. Instead, `main.tsx` fetches `/config.json` **before mounting React**: ```ts // main.tsx (both apps) await loadRuntimeConfig(); // fetch("/config.json") → cached createRoot(rootElement).render(); // anywhere else import { env } from "@/env"; env.apiBase // "" in production (same-origin), or an absolute URL env.defaultTenant // tenant header fallback, default "root" env.inactivityIdleMs // idle auto-logout timing ``` In dev, Vite serves `public/config.json`; in production, the Nginx container renders it from environment variables via `envsubst` at startup. Same image, every environment. The admin app additionally carries `dashboardUrl` (for the impersonation handoff); the dashboard carries `demoMode` (login-page demo-account picker). See [development](/docs/frontend/development/). ### Inactivity auto sign-out Both apps auto-sign-out idle sessions: admin after 10 minutes, dashboard after 20, each with a 60-second warning countdown — all four numbers configurable per deploy in `config.json`. The last-activity timestamp is shared across tabs via the `fsh.lastActivity` localStorage key so activity in any tab keeps the whole session alive. ## What both apps don't do - **No Redux / Zustand / global state manager.** TanStack Query is the server state; React's `useState` / `useReducer` is the local state. Avoid pulling in a global store unless you have a genuine cross-page client-state need. - **No CSS-in-JS.** Tailwind utilities + a global stylesheet (plus the odd per-feature CSS file, like chat's). The kit's design language stays consistent through utility classes. - **No API client codegen.** The `src/api/` wrappers are written by hand against the OpenAPI doc — fewer moving parts than a generator, and the types only cover what the UI actually uses. - **No microfrontends.** Both apps are single-bundle SPAs (with per-route lazy chunks). Module federation is appealing in slide decks; it's a real maintenance tax in practice. ## Related - [Admin console](/docs/frontend/admin/) — operator-facing surface. - [Tenant dashboard](/docs/frontend/dashboard/) — end-user surface. - [Theming](/docs/frontend/theming/) — per-tenant brand customisation. - [Local development](/docs/frontend/development/) — running, building, deploying. --- # Tenant dashboard > The end-user-facing React + Vite app at clients/dashboard — catalog, chat, files, tickets, invoices, identity admin, plus real-time SignalR + SSE feeds. Source: https://fullstackhero.net/docs/frontend/dashboard/ `clients/dashboard` is what your tenants' users see when they log in. Catalog browsing, realtime chat, file uploads, ticket filing, invoices and subscription, tenant-scoped identity administration, profile management. It's the surface your customers spend most of their time in, so it's the one to invest UX care in first. ## Tech stack Same shape as the [admin console](/docs/frontend/admin/) — React 19 + Vite 7 + TypeScript + TanStack Query v5 + React Router 7 + Tailwind 4 + Radix + Lucide. Differences worth knowing: | | Used for | |---|---| | `clients/dashboard/src/realtime/` | SignalR subscriptions (chat, notifications, presence) | | `clients/dashboard/src/sse/` | Server-Sent Events — the live activity feed on Overview + Activity | | `cmdk` | Command palette (Ctrl/Cmd-K) | | `@tanstack/react-virtual` | Virtualised long lists (chat message history) | The dashboard does **not** use react-hook-form or zod — those are admin-only; dashboard forms are plain controlled components. It subscribes to **more** realtime than admin — chat is a continuous-conversation surface, so SignalR is on the hot path. Like the admin app, the dashboard auto-signs-out idle sessions — **20 minutes** idle plus a **60-second** warning, configured per-deploy in `/config.json`. ## Page inventory ``` clients/dashboard/src/pages/ ├── overview.tsx Landing page — stats + live activity ├── activity.tsx Full-page SSE activity feed ├── auth/ Forgot/reset password, email confirmation (+ login.tsx) ├── catalog/ Products, brands, categories, product detail ├── chat/ Channels, DMs, threads, mentions, reactions, pinning, search ├── files/ My files — presigned upload, list, download, delete ├── identity/ Tenant-scoped admin: users, roles, groups (+ detail pages) ├── invoices.tsx Invoice list + invoice-detail.tsx ├── subscription.tsx Current plan + billing status ├── settings/ Profile, security (2FA), appearance, notifications, API keys ├── system/ Sessions, trash (recycle bin) — plus health + audits routes ├── tickets/ File ticket, list, detail, comments ├── impersonation-ended.tsx Graceful landing when an operator grant ends └── tenant-deactivated.tsx Terminal page when the tenant is deactivated mid-session ``` Plus shared chrome (sidebar, top bar, notification bell, command palette) in `clients/dashboard/src/components/`. Routes are lazy-loaded per page (`clients/dashboard/src/routes.tsx`). A few notes: - **Settings → API keys** is an honest "coming soon" placeholder, not a hidden 404. - **System → Trash** is the cross-module recycle bin (products, brands, categories, tickets, files). Its tabs — and the nav entry itself — are permission-gated client-side to mirror the server's restore/view-trash permissions, so users never click into a guaranteed 403. - The dashboard has no per-route `RouteGuard` like admin; auth is enforced by `ProtectedRoute`, and permission checks gate navigation entries + in-page actions. ## Appearance + theming The dashboard's appearance system is **per-user**: light/dark/system mode, an accent colour (presets or a custom hue), font, density, and reduced motion — all stored in localStorage and applied via CSS custom properties (Settings → Appearance). The neutral chassis is deliberately untinted so the accent does the branding work. Per-**tenant** branding lives server-side as the Multitenancy module's `TenantTheme` (palette, brand assets, typography, layout) with a permission-gated editor in the admin console. Full details: [theming](/docs/frontend/theming/). ## Chat — the realtime showcase `clients/dashboard/src/pages/chat/` is the densest page in either frontend. Slack-style channels with: - Three channel kinds — `Channel` (named), `DirectMessage` (1:1), `GroupMessage` (3+). - Message threads with reply count badges (replies hang off a `parentMessageId`). - Emoji reactions (toggle on/off, one row per emoji + count). - Pinned messages with a dedicated panel. - `@mention` autocomplete via `mention-picker.tsx` (queries the tenant's users as you type). - Typing indicators — the hub throttles per channel + user (~3 s); the client auto-clears stale markers. - Cursor-paginated message history with virtualised infinite scroll. - Real-time updates via SignalR — new messages, edits, deletes, reactions, channel reads. - Message search via `/api/v1/chat/search`. The chat page is the canonical example of how to build realtime UI against the kit's hub. Components don't touch the `HubConnection` directly — they subscribe through the realtime context: ```tsx // Simplified — see clients/dashboard/src/pages/chat/ for the real thing useRealtimeEvent("ChatMessageCreated", (msg) => { queryClient.setQueryData(/* append to the channel's message cache */); }); // Hub methods (e.g. typing notifications) go through invoke(): const { invoke } = useRealtime(); void invoke("Typing", channelId); ``` `useRealtimeEvent(event, handler)` and `useRealtime()` come from `realtime-context.tsx`, which owns one shared connection. One connection, many subscriptions. ## Catalog — products, brands, categories A product management surface over the Catalog module: paginated product list with search, brand + category list pages, and a product detail page with image gallery and an edit dialog. ## Files — presigned upload flow `clients/dashboard/src/pages/files/` (with the orchestration in `src/hooks/use-file-upload.ts`) implements the kit's three-step presigned upload: 1. Browser calls `POST /api/v1/files/upload-url` with metadata (file name, size, content type, category) — the server mints a presigned PUT URL and reserves a `FileAsset` row. 2. Browser uploads bytes directly to MinIO / S3 via the presigned URL — no proxy through the API — with real upload progress. 3. Browser calls `POST /api/v1/files/{id}/finalize` — the server verifies the object and flips the file from `PendingUpload` to `Available`. ## Tickets — file + track support requests Filing tickets from the dashboard, with priority + (optional) assignee + comments thread. ## Identity — tenant-scoped administration `pages/identity/` is where a tenant's own admins manage **their** users, roles, and groups — list + detail pages for each, including a role permission editor driven by the server's permission catalog (`GET /api/v1/identity/permissions/catalog`), so new module permissions appear automatically. Self-service for the signed-in user lives under **Settings** instead: ## Settings — profile, security, appearance, notifications Five tabs: Profile, Security (password + 2FA), Appearance (mode / accent / font / density / reduced motion), Notification preferences, and an API-keys placeholder. ## Realtime architecture Two transports, both mounted inside the authenticated shell (`app-shell.tsx`), so unauthenticated pages never pay for them: **SignalR** — `clients/dashboard/src/realtime/realtime-context.tsx` owns one shared connection to `/api/v1/realtime/hub`: ```tsx // Subscribe in a component — returns/cleans up automatically useRealtimeEvent("NotificationCreated", (n) => { /* ... */ }); ``` The context handles: - Lazy-loading the SignalR client bundle on first connect. - Token-aware rebuilds — login, refresh rotation, and impersonation switches all force a reconnect with the fresh token. - Automatic reconnect with backoff + jitter (capped at 60 s), with status (`idle / connecting / connected / reconnecting / error`) surfaced as a state pill. - Transport negotiation (WebSockets, then SSE, then long-polling) for proxy-hostile networks. **SSE** — `clients/dashboard/src/sse/sse-context.tsx` streams the live activity feed. The client first exchanges its JWT for a short-lived stream token (`POST /api/v1/sse/token`), then opens `GET /api/v1/sse/stream?token=...` and parses the `text/event-stream` itself (fetch + ReadableStream, with reconnect/backoff). Overview and the Activity page render the same event buffer. ## Local development ```bash cd clients/dashboard npm install npm run dev # http://localhost:5174 with HMR ``` The Vite dev server proxies `/api` (with WebSocket forwarding for SignalR), `/health`, `/openapi`, and `/scalar` to the backend — `https://localhost:7030` by default, overridable with a `VITE_API_BASE_URL` env var (dev proxy target only; runtime config comes from `/config.json`). In dev, Vite serves a `config.json` that points the long-lived SSE/SignalR streams straight at the API instead of through the proxy, so they don't starve the browser's per-host connection budget. The dashboard's `config.json` also carries a `demoMode` flag that surfaces a demo-account picker on the login page — a runtime flag, so a single build can enable it in staging and omit it in production. When run via the kit's Aspire AppHost, the dashboard wires automatically — the API, admin, and dashboard all come up with one command. ## Related - [Admin console](/docs/frontend/admin/) — operator-facing companion. - [Frontend architecture](/docs/frontend/architecture/) — shared patterns. - [Theming](/docs/frontend/theming/) — per-tenant brand customisation. - [Chat module](/docs/modules/chat/) — the API the chat surface wraps. - [Files module](/docs/modules/files/) — the API the upload flow wraps. --- # Local development > Running the React frontends with Vite HMR, building for production, deploying to static hosts or behind Nginx, runtime config via /config.json. Source: https://fullstackhero.net/docs/frontend/development/ Both `clients/admin` and `clients/dashboard` are Vite-based React SPAs. Standard `npm` lifecycle, standard build output, no special tooling required. `dotnet run --project src/Host/FSH.Starter.AppHost` brings up Postgres, Valkey, MinIO, the API, the admin, and the dashboard — all at once, with service discovery handling URLs. You don't need to run `npm run dev` separately unless you're isolating frontend work. ## Running locally ### Via Aspire (recommended) ```bash dotnet run --project src/Host/FSH.Starter.AppHost ``` This starts everything: - API at https://localhost:7030 (http on 5030) - Admin at http://localhost:5173 - Dashboard at http://localhost:5174 - Postgres at tcp/5432, Valkey at tcp/6379, MinIO at :9000 Vite HMR works through Aspire — edits to frontend files reload instantly in the browser. ### Via npm (frontend-only) ```bash cd clients/admin # or clients/dashboard npm install # first time only npm run dev # http://localhost:5173 (dashboard: 5174) ``` This requires the backend to be running separately (either via Aspire, docker-compose, or `dotnet run` against the API project). The Vite dev server proxies API paths to the backend — `http://localhost:5030` for admin, `https://localhost:7030` for the dashboard by default. To point at a different backend, set `VITE_API_BASE_URL` when starting the dev server; it only steers the **dev proxy target**, it is never compiled into a build. ## The npm scripts ```jsonc // clients/admin/package.json (dashboard is identical apart from ports) { "scripts": { "dev": "vite --port 5173", // dev server with HMR "build": "tsc -b && vite build", // typecheck + production bundle "preview": "vite preview --port 4173",// serve the production bundle locally "lint": "eslint .", "test:e2e": "playwright test", // route-mocked E2E suite "test:e2e:ui": "playwright test --ui" } } ``` The `build` script runs the TypeScript project-references compiler first (`tsc -b`) so type errors fail the build. Vite handles transpilation + bundling. ## Production build ```bash cd clients/admin npm run build ``` Output: ``` clients/admin/dist/ ├── index.html ├── assets/ │ ├── index-{hash}.js │ ├── index-{hash}.css │ └── ... └── ... ``` `dist/` is a static site — pure HTML / JS / CSS. No server runtime required. ## Runtime configuration (`/config.json`) Vite hardcodes `VITE_*` env vars into the bundle at build time. That's fine for static URLs, but you usually want **one built image** that runs against multiple environments (dev, staging, prod) with different API URLs. The kit's solution: each app fetches `/config.json` once at boot — before React mounts — and reads every environment-dependent value from it. In dev, Vite serves `public/config.json`; in production, the container renders it from environment variables at startup. ```jsonc // clients/admin/public/config.json (dev defaults) { "apiBase": "", // "" = same-origin (dev proxy / reverse proxy) "defaultTenant": "root", "dashboardUrl": "http://localhost:5174", // admin only — impersonation handoff target "inactivityIdleMs": 600000, "inactivityWarningMs": 60000 } ``` The dashboard's variant swaps `dashboardUrl` for a `demoMode` flag (login-page demo-account picker). ### The container pattern `clients/{app}/Dockerfile` is a two-stage build — `node:22-alpine` runs `npm ci && npm run build`, then `nginx:alpine` serves `dist/`. The entrypoint renders the runtime config before starting Nginx: ```sh # clients/{app}/docker/docker-entrypoint.sh : "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}" : "${FSH_DEFAULT_TENANT:=root}" envsubst < /usr/share/nginx/html/config.json.template \ > /usr/share/nginx/html/config.json exec nginx -g 'daemon off;' ``` ```jsonc // clients/admin/docker/config.json.template { "apiBase": "${FSH_API_URL}", "defaultTenant": "${FSH_DEFAULT_TENANT}", "dashboardUrl": "${FSH_DASHBOARD_URL}" } ``` At deploy time, set the env vars on the container: ```bash docker run -e FSH_API_URL=https://api.example.com -e FSH_DASHBOARD_URL=https://app.example.com fsh-admin ``` The same image runs everywhere; only env vars differ. ### Reading runtime config in code ```ts // main.tsx awaits this before mounting React await loadRuntimeConfig(); // fetch("/config.json", { cache: "no-store" }) // everywhere else import { env } from "@/env"; env.apiBase; env.defaultTenant; env.inactivityIdleMs; ``` `env.*` throws if read before the config loads — a deliberate fail-fast so a misordered import surfaces immediately instead of silently using the wrong API URL. ## Nginx configuration for SPAs The kit's actual file, `clients/{app}/docker/nginx.conf`: ```nginx server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # Long-lived caching for hashed asset bundles location ~* \.(?:js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ { expires 1y; add_header Cache-Control "public, immutable"; } # Runtime config — must be re-fetched on every deploy location = /config.json { add_header Cache-Control "no-store"; add_header X-Content-Type-Options "nosniff"; } # SPA fallback with security headers location / { try_files $uri /index.html; add_header X-Frame-Options "DENY"; add_header X-Content-Type-Options "nosniff"; add_header Referrer-Policy "strict-origin-when-cross-origin"; } # Block dotfiles location ~ /\. { deny all; } } ``` ## Deploying to a CDN The kit's frontends are CDN-friendly. Three deployment shapes: ### Vercel / Netlify Drag-drop the `dist/` folder, or connect the repo and configure: - Build command: `cd clients/admin && npm install && npm run build` - Output directory: `clients/admin/dist` For runtime config, ship a per-environment `config.json` alongside the build output (it's just a static file), or generate it in the build command. ### S3 + CloudFront 1. `npm run build`, then `aws s3 sync dist/ s3://fsh-admin-bucket/` 2. CloudFront distribution in front, with a default-root-object of `index.html` + a custom error response that serves `index.html` for 404 (so SPA routing works). 3. Set cache TTLs: no-store on `config.json`, short on `index.html`, long on the hashed assets. ### Cloudflare Pages Connect the repo, configure build command + output directory. Cloudflare's Workers can inject runtime env via Pages Functions if needed. ## Testing Both apps ship Playwright E2E suites — fully **route-mocked** (no live backend required) with seeded JWTs, so they run fast and deterministic in CI: ```bash npm run test:e2e # headless run npm run test:e2e:ui # Playwright's interactive UI mode ``` Tests live under `clients/{app}/tests/`. There is no unit-test runner (Vitest) wired — the coverage strategy is E2E against mocked routes plus the backend's own xUnit + integration suites. ## Linting ESLint is wired (flat config at `clients/{app}/eslint.config.js`, extending the React + TypeScript + a11y rule sets): ```bash npm run lint # report npm run lint -- --fix # auto-fix ``` ## The dev server proxy Vite proxies API paths to the backend so you don't deal with CORS during development: ```ts // clients/dashboard/vite.config.ts (excerpt) server: { port: 5174, strictPort: true, proxy: { // ws: true forwards the WebSocket upgrade used by SignalR's hub "/api": { target: apiBase, changeOrigin: true, secure: false, ws: true }, "/health": { target: apiBase, changeOrigin: true, secure: false }, "/openapi": { target: apiBase, changeOrigin: true, secure: false }, "/scalar": { target: apiBase, changeOrigin: true, secure: false }, }, }, ``` With this in place, relative URLs (`/api/v1/...`) work in dev, matching the production same-origin default (`"apiBase": ""`). One nuance: the dashboard's dev server serves a `config.json` whose `apiBase` points **directly** at the API, so the long-lived SSE + SignalR streams bypass the proxy — otherwise they pin connections to `localhost:5174` and HTTP/1.1's ~6-per-host cap can starve lazy route-chunk loads. ## Common issues - **CORS errors in dev** — your proxy isn't picking up the right backend URL. Set `VITE_API_BASE_URL` when starting `npm run dev`, or fix the backend's CORS config if you're hitting it directly. - **SignalR fails to connect after deploy** — your reverse proxy isn't forwarding WebSocket upgrade headers. Add `proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";` to Nginx. - **App boots to an error about `/config.json`** — the runtime config file is missing or unreachable. In containers, check the `FSH_*` env vars (the entrypoint refuses to start without `FSH_API_URL`); on static hosts, make sure `config.json` deployed next to `index.html`. - **HMR not updating in Aspire** — `dotnet watch` doesn't always proxy WebSocket upgrades for Vite. Run `npm run dev` separately in that case. ## Related - [Admin console](/docs/frontend/admin/) — the operator app. - [Tenant dashboard](/docs/frontend/dashboard/) — the end-user app. - [Frontend architecture](/docs/frontend/architecture/) — shared patterns. - [Deployment](/docs/deployment/) — production wiring across services. --- # Per-tenant theming > How tenant branding works in the kit — the TenantTheme aggregate in the Multitenancy module, the admin branding editor, and the dashboard's per-user appearance system. Source: https://fullstackhero.net/docs/frontend/theming/ Theming in the kit has two layers that are easy to conflate, so let's name them up front: 1. **Per-tenant branding** — the Multitenancy module owns a `TenantTheme` aggregate (colour palettes, brand assets, typography, layout) with permission-gated read/update/reset endpoints and an editor in the admin console. Themes are **data, not deploys** — editing one never requires a rebuild. 2. **Per-user appearance** — the dashboard ships a rich appearance system (light/dark/system mode, accent colour, font, density, reduced motion) that each **user** controls from Settings → Appearance and that persists in localStorage. The `TenantTheme` store + editor are fully wired end-to-end on the backend and admin side. The dashboard does **not yet** read `TenantTheme` to repaint its chrome per tenant — its visual identity today comes from the per-user appearance system. If you want full white-label rendering, the wiring point is small and described [below](#rendering-tenanttheme-in-your-own-frontend). ## The `TenantTheme` model The aggregate lives at `src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs`; the API exchanges a structured `TenantThemeDto` (light palette, dark palette, brand assets, typography, layout). ### Palette Light mode + dark mode values for nine roles: | Role | Default light | Default dark | Used for | |---|---|---|---| | Primary | `#2563EB` | `#38BDF8` | Buttons, links, accents | | Secondary | `#0F172A` | `#94A3B8` | Secondary buttons, badges | | Tertiary | `#6366F1` | `#818CF8` | Tertiary accents, dividers | | Background | `#F8FAFC` | `#0B1220` | App background | | Surface | `#FFFFFF` | `#111827` | Cards, sheets, dialogs | | Error | `#DC2626` | `#F87171` | Destructive actions, error banners | | Warning | `#F59E0B` | `#FBBF24` | Cautionary states | | Success | `#16A34A` | `#22C55E` | Success states | | Info | `#0284C7` | `#38BDF8` | Informational states | ### Brand assets - `LogoUrl` — light-mode logo. - `LogoDarkUrl` — dark-mode logo. - `FaviconUrl` — browser tab icon. Uploads go through the kit's storage service — the update endpoint accepts inline image data, stores it, and replaces (and cleans up) the previous asset. ### Typography - `FontFamily` — body font (default `Inter, sans-serif`). - `HeadingFontFamily` — display font for headings (default `Inter, sans-serif`). - `FontSizeBase` — body font size in px (default 14). - `LineHeightBase` — body line height (default 1.5). ### Layout - `BorderRadius` — radius for cards, buttons, inputs (CSS string, default `4px`). - `DefaultElevation` — shadow strength (default 1). ### Default theme `IsDefault` marks one theme as the platform default. Only the **root tenant** can set it; any tenant without its own saved theme resolves to the kit's built-in defaults. ## The endpoints All three are **current-tenant-scoped** — they act on whichever tenant the request's `tenant` header resolves to (there's no `{id}` in the route) — and permission-gated: | Endpoint | Permission | Does | |---|---|---| | `GET /api/v1/tenants/theme` | `Tenants.ViewTheme` | Returns the tenant's theme, or the defaults if none is saved | | `PUT /api/v1/tenants/theme` | `Tenants.UpdateTheme` | Upserts the theme (validated — colour formats, font sizes) | | `POST /api/v1/tenants/theme/reset` | `Tenants.UpdateTheme` | Resets every field to the kit defaults | Reads are cached via HybridCache (1 hour distributed / 2 minutes local) and invalidated on every update or reset, so theme fetches don't hit the database per request. ## Editing themes in the admin console The branding card on the admin console's **tenant detail page** is the operator-facing editor. It scopes each call to the target tenant by overriding the `tenant` header — only root operators get past that override — and covers the light + dark palettes plus brand assets. Typography and layout exist on the DTO but are intentionally left out of the v1 editor; wire them in if your product needs them. **Save** persists via `PUT /api/v1/tenants/theme`; **Reset** calls `POST /api/v1/tenants/theme/reset`. Both invalidate the server-side cache immediately. ## The dashboard's appearance system (per-user) What a dashboard user can customise today is personal, not tenant-wide — Settings → Appearance: - **Mode** — light / dark / system, applied with a View Transitions crossfade. - **Accent** — preset accent colours or a fully custom hue, applied as the eleven `--brand-*` CSS custom-property stops. - **Font, density, reduced motion** — per-user toggles. Everything persists in localStorage (`fsh.theme`, accent/font/density keys) and applies before the app paints, so there's no flash of the wrong mode. The dashboard's neutral greys are deliberately **untinted** (zero chroma) so whatever accent the user — or eventually the tenant — picks does the branding work without fighting a tinted chassis. ## Rendering `TenantTheme` in your own frontend The server side is done; consuming it is the part you own. The shape: 1. After login, call `GET /api/v1/tenants/theme` (the signed-in user needs `Tenants.ViewTheme`). 2. Map the DTO onto CSS custom properties on `document.documentElement` — palette roles to colour variables, typography to `--font-*`, `BorderRadius` to your radius token. 3. Swap the logo / favicon from `BrandAssets`. 4. Cache the last-applied theme in localStorage and re-apply it synchronously in `index.html` before React mounts, so returning users see their brand on first paint instead of a default flash. Because every kit component already resolves colours through CSS variables, step 2 is a mapping exercise, not a redesign. ## What theming doesn't do - **It's not a full white-label.** The component library (sidebar shape, navigation pattern, dialog conventions) stays the same. Theming changes colours, fonts, and assets; it doesn't change layout. - **It doesn't theme the admin console.** Admin is for your operators; it stays in the kit's own palette so operators have a consistent surface across tenants. - **It doesn't ship a theme marketplace.** Tenants get one theme each; there's no "browse themes" gallery. If you want that, add a `ThemeTemplate` aggregate with a one-click apply. ## Related - [Multitenancy module](/docs/modules/multitenancy/) — the `TenantTheme` aggregate + endpoints. - [Tenant dashboard](/docs/frontend/dashboard/) — the per-user appearance system. - [Admin console](/docs/frontend/admin/) — the branding editor surface. --- # Overview > Install the kit, run it locally, and understand the project layout. Source: https://fullstackhero.net/docs/getting-started/ Everything you need to spin up a fresh project, in order. Start with the **Introduction** for the mental model, then jump straight to **Quick Start** if you just want to run the thing. --- # Demo accounts & seed data > Every demo tenant, user persona, and piece of sample data the seed-demo verb creates — what you can log in as and what you'll see. Source: https://fullstackhero.net/docs/getting-started/demo-accounts/ When the Aspire stack comes up, the dashboard login page (`http://localhost:5174`) shows a **"Sign in with a demo account"** button. One click drops you into any persona — platform owner, tenant admin, support agent, regular member — without typing a thing. This page is the reference for what those accounts are and what data sits behind them. Every password on this page is hardcoded in the repo, which means it's public knowledge. `seed-demo` is dev-only by design: the migrator **refuses to run the verb unless the environment is `Development`** and exits non-zero. Don't work around that guard, and don't ship a production `config.json` with `demoMode: true` — the picker would advertise credentials that (hopefully) don't exist there. ## Two layers of seeding The DbMigrator does all seeding — the API never mutates data at startup. There are two distinct layers, and it helps to keep them straight: | | `apply --seed` (baseline) | `seed-demo` (demo content) | |---|---|---| | **When** | Every environment, including production | Dev only — hard-refuses elsewhere | | **Tenants** | `root` only (on first run) | Adds `acme` and `globex` | | **Users** | The tenant admin per tenant — `admin@root.com` on root | 10 Acme users, 1 Globex user, `superadmin@root.com`, plus password realignment | | **Roles** | `Admin` + `Basic` with their permission claims, an Administrators group | Custom `Manager` + `Support` roles in Acme | | **Billing** | Default plans: `free`, `pro`, `pro-annual` | Active subscriptions + a term invoice for paid plans | | **Content** | None — fresh tenants start empty | Catalog, tickets, chat channels + messages | | **Password source** | `Seed:DefaultAdminPassword` | `Seed:DemoPassword` | So `admin@root.com` exists in *every* deployment (it comes from baseline seeding), while everything else on this page only exists after `seed-demo`. Both layers are idempotent — every step checks before writing, so re-running against an already-seeded database is a no-op. ## The shared demo password Every demo account signs in with: ```text Password123! ``` It's sourced from `Seed:DemoPassword` (the Aspire AppHost sets it; the API's `appsettings.Development.json` carries the same value). The seeder also **realigns the tenant admin passwords** — `admin@root.com`, `admin@acme.com`, `admin@globex.com` — to this shared password, so in the dev stack the root admin signs in with `Password123!` too. A deployment that runs only `apply --seed` (i.e. any non-dev environment) keeps whatever `Seed:DefaultAdminPassword` you configured for the root admin. The Aspire dev default for that is `123Pa$$word!`, but it's irrelevant in dev because `seed-demo` overwrites it. ## The dashboard demo picker The picker groups accounts by tenant, exactly as defined in `clients/dashboard/src/pages/login.demo-accounts.ts`. Picking one fills tenant + email + password and signs in instantly. ### Root — platform owner, super-tenant | Login | Name | Role | Use it to… | |---|---|---|---| | `admin@root.com` | Root Admin | Tenant Admin | Act as the platform owner — manage tenants, billing plans, all permissions | ### Acme Corp — the populated tenant Acme is where most flows make sense: full catalog, open tickets, busy chat. | Login | Name | Role | Use it to… | |---|---|---|---| | `admin@acme.com` | Acme Admin | Tenant Admin | See everything a tenant admin can do — full access | | `manager@acme.com` | Maya Lin | Manager (custom role) | Manage catalog + tickets with read-only users — a mid-tier custom role | | `support@acme.com` | Sam Rivera | Support (custom role) | Work tickets only — watch most of the nav disappear | | `alice@acme.com` | Alice Nguyen | Basic | Experience the default-member baseline and permission gating | | `bob@acme.com` | Bob Patel | Basic | Same as Alice — handy for two-browser chat/DM testing | ### Globex — the sparse tenant Globex is the "just onboarded" contrast: one extra user, two tickets, one quiet channel, and the free plan. | Login | Name | Role | Use it to… | |---|---|---|---| | `admin@globex.com` | Globex Admin | Tenant Admin | A tenant admin on the free plan with minimal activity | | `dave@globex.com` | Dave Hartwell | Basic | A lone member in a near-empty tenant | ### Accounts that exist but aren't in the picker The seeder creates more Acme members than the picker advertises — they exist to make chat and tickets feel lived-in. You can log in as any of them manually with the shared password: `carol@`, `dan@`, `erin@`, `frank@`, `gina@`, `henry@acme.com` (all Basic). There's also `superadmin@root.com` (Admin role on the root tenant) — that's the account the **admin app's** demo picker surfaces, see below. ## What `seed-demo` puts in each tenant | Data | Acme Corp | Globex | |---|---|---| | Plan / subscription | `pro-annual` (active) + an issued term invoice | `free` (active), no invoice — free plans don't invoice, same as production | | Users | Admin + 10 members, incl. the `Manager` and `Support` custom roles | Admin + 1 member | | Catalog | 4 brands, 11 categories, 10 products | Same dataset (catalog seeds for both demo tenants) | | Tickets | 6 tickets (`TK-1`–`TK-6`): 2 resolved, varied priorities, comments, assignees | 2 open tickets reported by Dave, unassigned | | Chat | `#general` + `#random` (public), `#engineering` (private), and an Alice↔Bob DM, all with messages | `#general` with a single welcome message | The custom roles are worth poking at — they're the demo for permission-driven UI: | Role | What it grants | |---|---| | **Manager** | Full CRUD on brands, categories, products, and tickets; view + update users; view roles, groups, sessions; revoke sessions | | **Support** | View, create, and update tickets; view users and sessions; revoke sessions — no catalog, no deletes | Fresh tenants you create yourself get none of this — they start with an empty catalog and populate via the API or admin UI. ## How seeding runs ### In the Aspire dev stack `dotnet run --project src/Host/FSH.Starter.AppHost` runs the migrator twice as one-shot jobs, and the API waits for both: 1. **Migrator** — `apply --seed`: migrations + baseline seed (root tenant, roles, `admin@root.com`, default plans). 2. **Demo seeder** — `seed-demo` with `DOTNET_ENVIRONMENT=Development`: everything on this page. ### Manually ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo ``` Two things must be true for the verb to run: - The environment must be `Development`. The migrator is a console host, so set `DOTNET_ENVIRONMENT=Development` (it ignores `ASPNETCORE_ENVIRONMENT`). - `Seed:DemoPassword` must be configured. The migrator links the API's `appsettings.Development.json`, which already carries `Password123!` — so in the Development environment this just works. Override with the `Seed__DemoPassword` environment variable if you want a different credential. The default connection string targets `localhost` Postgres; override `DatabaseOptions__ConnectionString` if your database lives elsewhere. Run `--help` on the migrator for the full verb/flag list. ## Turning the picker off The two apps gate their pickers differently: - **Dashboard** — a runtime flag: `"demoMode": true` in `clients/dashboard/public/config.json`. It's runtime (not a `VITE_` build-time var) so a single build artifact can be promoted across environments — set it `true` in staging's `config.json`, `false` or omit it in production's. Omitted means **off**. - **Admin** — gated on `import.meta.env.DEV`, so it only exists in dev builds (`npm run dev`). A production build never includes it; there's nothing to configure. It surfaces exactly one account: `superadmin@root.com` / `Password123!` on the `root` tenant. The picker is a static list in the frontend — it never calls the API (the login page is unauthenticated, and the API shouldn't advertise credentials). If you add a demo account on the backend, mirror it in `login.demo-accounts.ts` by hand. ## Related - [Quick Start](/docs/getting-started/quick-start/) — bring the whole stack up with one command. - [Operator impersonation](/docs/guides/operator-impersonation/) — what the root operator can do once signed in. --- # Install > Every way to get the kit — the fsh CLI, the dotnet new template, a git clone, and GitHub (Use this template / Codespaces). Source: https://fullstackhero.net/docs/getting-started/install/ Make sure you've got everything in [Prerequisites](/docs/getting-started/prerequisites/) first. There are four ways to get the kit — pick based on whether you want a fresh renamed project, the raw source, or zero local install. Both the `fsh` CLI (`FullStackHero.CLI`) and the `dotnet new` template (`FullStackHero.NET.StarterKit`) are published as stable **10.0.0**. The one-command flow — install the CLI, then `fsh new MyApp` — works end-to-end. ## Which path? | Path | Renames the project | Best for | |---|---|---| | **fsh CLI** *(recommended)* | Yes | Starting a new app; interactive setup + environment check | | **dotnet new template** | Yes | Scripted/CI scaffolding without the CLI | | **git clone** | No | Reading the source, contributing, or running the kit as-is | | **GitHub template / Codespaces** | Yes (via GitHub) | Zero local install, or your own repo from the template | `fsh new` is the smoothest path: it scaffolds a fully renamed project, swaps in a unique JWT signing key, generates strong Docker secrets, runs `npm install`, and makes the first commit — the setup you'd otherwise do by hand. ## Path 1 — The fsh CLI (recommended) A NuGet-distributed dotnet tool that scaffolds a fresh, fully renamed project from the latest template: ```bash dotnet tool install -g FullStackHero.CLI fsh doctor # verify .NET SDK, Git, Docker, the template, free ports fsh new MyApp # interactive: include Aspire? include the React apps? cd MyApp dotnet run --project src/Host/MyApp.AppHost ``` Non-interactive (handy for scripts) — skips the prompts and uses defaults: ```bash fsh new MyApp --non-interactive ``` See the [CLI reference](/docs/cli/) for every command (`new`, `doctor`, `info`, `update`) and flag. ## Path 2 — The dotnet new template `fsh new` scaffolds from this template under the hood; you can also use it directly without the CLI — handy for scripted or CI scaffolding: ```bash dotnet new install FullStackHero.NET.StarterKit dotnet new fsh -n MyApp cd MyApp dotnet run --project src/Host/MyApp.AppHost ``` ## Path 3 — Clone the repository Get the full source as-is, with no project renaming (the solution keeps its `FSH.Starter` names): ```bash git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp cd MyApp dotnet restore src/FSH.Starter.slnx dotnet run --project src/Host/FSH.Starter.AppHost ``` This is the path for reading the code, [contributing](/docs/contributing/), or running the kit unchanged. ## Path 4 — GitHub: Use this template / Codespaces On the [GitHub repo](https://github.com/fullstackhero/dotnet-starter-kit), click **Use this template** to create your own repository from the kit — or **Open in Codespaces** for a zero-install dev environment (the devcontainer ships the .NET 10 SDK and Docker-in-Docker, restores the solution, and forwards the API ports). First Codespaces boot takes a couple of minutes; subsequent ones are instant. ## Verify it's running However you installed, the AppHost launches the Aspire dashboard. Every service should reach a healthy/running state, and the API serves OpenAPI + Scalar at `https://localhost:7030/scalar`. ## Next - **[Quick Start](/docs/getting-started/quick-start/)** — the minimal scaffold-and-run walkthrough. - **[Local Orchestration with Aspire](/docs/deployment/aspire/)** — what each service is and why it's wired that way. - **[Architecture](/docs/architecture/)** — the modular monolith + vertical slice mental model. --- # Introduction > What fullstackhero is, what it ships with, and how it's put together. Source: https://fullstackhero.net/docs/getting-started/introduction/ fullstackhero is an opinionated, production-first .NET 10 starter for building multi-tenant SaaS and enterprise APIs. You get the full source — BuildingBlocks, Modules, and a composition-root Host — wired through Minimal APIs, Mediator, and EF Core. No black-box NuGet packages: you own and can modify everything. It's aimed at teams that want to ship features on day one without spending a sprint wiring identity, tenancy, auditing, caching, jobs, storage, observability, and API docs. ## Highlights - **Modular monolith, vertical slices.** Each module is a bounded context with its own DbContext, contracts, and feature folders. Communication across modules goes through `*.Contracts` projects only — runtime projects never reference each other. - **Multi-tenant from day one.** Finbuckle-powered tenancy across Identity and module DbContexts. Tenant resolution via claim, header, or query string. Per-tenant migrations and seeding run through a dedicated DbMigrator — the API never touches the schema at startup. - **Identity + permission-based auth.** ASP.NET Identity with JWT issue/refresh, roles, granular permissions enforced via `.RequirePermission()` on endpoints. - **Eventing built in.** Domain events on aggregates + integration events through Mediator. Outbox-friendly entity model. - **Webhooks with HMAC signing.** Tenant-scoped subscriptions, retries, and delivery logs. - **Realtime + SSE.** SignalR for hubs, Server-Sent Events for one-way streaming. - **Idempotency.** `Idempotency-Key` header with cache-backed replay. - **Feature flags with tenant overrides.** Microsoft.FeatureManagement, scoped per tenant. - **Quotas.** Per-tenant request and resource limits. - **Observability baked in.** OpenTelemetry traces, metrics, and logs (OTLP); Serilog structured logging; health checks; security/exception auditing. - **API docs.** OpenAPI + Scalar UI. Versioned routes via `Asp.Versioning` (`/api/v{version}/{module}`). - **Cloud-ready.** `.NET Aspire` AppHost brings up Postgres, Valkey (a Redis-compatible, BSD-licensed Redis fork), MinIO, the migrator, the API, and both React apps locally with one command. Production deploy via Docker Compose included. - **Two frontends.** React + Vite admin console and dashboard, both in `clients/`. - **CLI + template.** Scaffold a fresh, fully renamed project in one command with the `fsh` CLI (`FullStackHero.CLI`) or the `dotnet new fsh` template (`FullStackHero.NET.StarterKit`) — both on NuGet as stable 10.0.0. Or clone the repo / use the GitHub template. ## High-level architecture ``` dotnet-starter-kit/ ├── src/ │ ├── BuildingBlocks/ Shared framework libraries │ │ ├── Core DDD primitives, exceptions, abstractions │ │ ├── Persistence EF Core base contexts, specifications, soft delete │ │ ├── Web Host wiring: auth, OpenAPI, rate limit, CORS, errors │ │ ├── Caching HybridCache + Valkey │ │ ├── Eventing Domain + integration events │ │ ├── Eventing.Abstractions Contracts for cross-module events │ │ ├── Jobs Hangfire │ │ ├── Mailing MailKit / SendGrid │ │ ├── Storage Local + S3-compatible │ │ ├── Quota Per-tenant limits │ │ └── Shared Permission constants, common contracts │ │ │ ├── Modules/ Bounded contexts (each = runtime + contracts pair) │ │ ├── Identity ASP.NET Identity, JWT, roles, permissions │ │ ├── Multitenancy Tenant CRUD, migrations, upgrades │ │ ├── Auditing Activity / security / exception audit trail │ │ ├── Webhooks HMAC-signed delivery, retries, logs │ │ ├── Files Upload URLs, storage abstraction │ │ ├── Notifications In-app notifications │ │ └── Billing · Catalog · Chat · Tickets Sample modules │ │ │ ├── Host/ │ │ ├── FSH.Starter.Api Composition root (Program.cs) │ │ ├── FSH.Starter.AppHost .NET Aspire orchestrator │ │ ├── FSH.Starter.DbMigrator Migrations + seed-demo CLI │ │ └── FSH.Starter.Migrations.PostgreSQL │ │ │ ├── Tools/CLI The `fsh` CLI (Spectre.Console) │ └── Tests/ Architecture tests + per-module test projects │ ├── clients/ │ ├── admin React + Vite admin console │ └── dashboard React + Vite dashboard │ └── deploy/ Docker Compose, Dokploy, Terraform ``` Module boundaries are enforced by NetArchTest rules in `src/Tests/Architecture.Tests`. ### Feature slice layout Each feature is a vertical slice. For `Modules.Identity` registering a user: ``` Modules.Identity.Contracts/v1/Users/RegisterUser/ RegisterUserCommand.cs RegisterUserResponse.cs Modules.Identity/Features/v1/Users/RegisterUser/ RegisterUserEndpoint.cs ← Minimal API RegisterUserCommandHandler.cs ← Mediator handler RegisterUserCommandValidator.cs ← FluentValidation ``` Handlers are `public sealed`, return `ValueTask`, and use `.ConfigureAwait(false)`. Endpoints are static extensions on `IEndpointRouteBuilder`. ### Composition root `src/Host/FSH.Starter.Api/Program.cs` wires the platform in three calls: ```csharp builder.Services.AddMediator(o => { o.Assemblies = [/* command + handler markers */]; }); builder.AddHeroPlatform(o => { o.EnableCaching = true; o.EnableMailing = true; o.EnableJobs = true; o.EnableQuotas = true; o.EnableSse = true; o.EnableRealtime = true; }); builder.AddModules(moduleAssemblies); var app = builder.Build(); app.UseHeroMultiTenantDatabases(); app.UseHeroPlatform(p => { p.MapModules = true; /* ... */ }); ``` The API never mutates data on startup. Demo data (acme/globex tenants, sample catalog/tickets/chat) is provisioned by `FSH.Starter.DbMigrator seed-demo`, which Aspire runs automatically on launch (the `fsh-starter-demo-seeder` resource) — so the demo logins work out of the box. ## Stack ### Runtime | Concern | Technology | |---------------------|-------------------------------------------------------------------------| | Framework | .NET 10 / C# latest | | Solution | `.slnx` (XML) with central package management (`Directory.Packages.props`) | | Web | Minimal APIs, `Asp.Versioning.Http` 10 | | Mediator | Mediator 3.0.2 (source generator — no reflection) | | Validation | FluentValidation 12 | | ORM | EF Core 10 | | Database | PostgreSQL (Npgsql) by default; SQL Server provider available | | Identity | ASP.NET Identity + JWT bearer (`Microsoft.AspNetCore.Authentication.JwtBearer` 10) | | Multitenancy | Finbuckle.MultiTenant 10 (claim / header / query strategies) | | Caching | `HybridCache` (in-memory + Valkey) via `StackExchange.Redis` | | Jobs | Hangfire 1.8 (Postgres or in-memory) | | HTTP resilience | `Microsoft.Extensions.Http.Resilience` (Polly v8) | | Feature flags | Microsoft.FeatureManagement + tenant overrides | | Realtime | SignalR (Valkey backplane) + Server-Sent Events | | Mail | MailKit / SendGrid | | Storage | Local filesystem or S3-compatible (AWS SDK) | | Messaging | RabbitMQ client (optional) | | Logging | Serilog 4 (structured) | | Tracing / metrics | OpenTelemetry 1.15 (OTLP exporter) | | API docs | OpenAPI 10 + Scalar UI | | Errors | `ProblemDetails` (RFC 9457) via global exception handler | | Orchestration | .NET Aspire 13.4 (Postgres + Valkey + MinIO + API + both React apps) | ### Frontends | App | Stack | |-------------|--------------------------------| | `clients/admin` | React + Vite | | `clients/dashboard` | React + Vite | ### Testing | Layer | Tools | |------------------|------------------------------------------------------------------| | Unit | xUnit, Shouldly, NSubstitute, AutoFixture | | Integration | `Microsoft.AspNetCore.Mvc.Testing`, Testcontainers (Postgres / Valkey / Minio) | | Architecture | NetArchTest — enforces module boundaries | ### Distribution | Channel | Command | |------------------------|--------------------------------------------------| | Clone the repo | `git clone https://github.com/fullstackhero/dotnet-starter-kit.git` | | GitHub Codespaces | "Use this template" → open in Codespaces | | FSH CLI *(recommended)* | `dotnet tool install -g FullStackHero.CLI && fsh new MyApp` | | `dotnet new` template | `dotnet new install FullStackHero.NET.StarterKit && dotnet new fsh -n MyApp` | ## Where to go next - **[Prerequisites](/docs/getting-started/prerequisites/)** — what you need installed. - **[Quick Start](/docs/getting-started/quick-start/)** — run a project in 60 seconds. - **[Architecture](/docs/architecture/)** — modular monolith, VSA, module boundaries. - **[Modules](/docs/modules/)** — Identity, Multitenancy, Auditing, Webhooks. - **[Guides](/docs/guides/)** — task-oriented walkthroughs. --- # Prerequisites > What you need installed locally before running a fullstackhero project. Source: https://fullstackhero.net/docs/getting-started/prerequisites/ A short checklist. The rest of Getting Started assumes these are in place. ## Required - **.NET 10 SDK** — from [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download). The repo's `global.json` pins SDK `10.0.100` (rolling forward to the latest feature band). - **Docker** — used for the Postgres, Valkey, and MinIO containers (Aspire and the integration tests rely on it). Docker Desktop on macOS / Windows, Docker Engine on Linux. - **Git** — for cloning the repo. No separate Aspire install is needed: the AppHost project pulls the Aspire SDK (`Aspire.AppHost.Sdk`) from NuGet during restore. ## Optional - **Node 20+** — only if you'll run the React + Vite frontends in `clients/admin` and `clients/dashboard` (both declare `"node": ">=20"`). - **PostgreSQL** — install locally only if you'd rather not run Postgres in a container. Otherwise Aspire provisions one for you (the kit's containers run Postgres 18). - **Valkey** — same. The kit runs Valkey 9 in its containers (a Redis-compatible, BSD-licensed Redis fork); any Redis-protocol server also works. ## Verify ```bash dotnet --version # 10.x.x docker info # should print server info, not error git --version ``` If `dotnet --version` reports 9.x or older, install the .NET 10 SDK before continuing — the kit targets `net10.0` and won't build on earlier SDKs. ## Next [Quick Start](/docs/getting-started/quick-start/) → clone the repo and run it. --- # Quick Start > Clone fullstackhero and run the whole stack locally with one command. Source: https://fullstackhero.net/docs/getting-started/quick-start/ This page clones the repo as-is (keeping its `FSH.Starter` names). To scaffold a fully renamed project in one command, install the `fsh` CLI and run `fsh new MyApp` instead — see [Install](/docs/getting-started/install/) for every path. The quickest way to run the kit as-is is to clone the repo and start it with .NET Aspire. ## 1. Clone the repo ```bash git clone https://github.com/fullstackhero/dotnet-starter-kit.git MyApp cd MyApp ``` A clone keeps the original `FSH.Starter` project names — it doesn't rename the solution. To get a fully renamed project instead, use `fsh new MyApp` (see [Install](/docs/getting-started/install/)). ## 2. Install frontend dependencies (one time) Aspire launches both React apps for you, but it doesn't install their packages: ```bash cd clients/admin && npm install cd ../dashboard && npm install cd ../.. ``` Skip this if you only want the API. ## 3. Run ```bash dotnet run --project src/Host/FSH.Starter.AppHost ``` Aspire brings up Postgres (with pgAdmin), Valkey, and MinIO in containers, runs the DbMigrator (migrations + seed) and the demo seeder, then starts the API and both React apps — all wired together with connection strings and OTLP export. Watch everything come up in the Aspire dashboard. Once it's green: | What | Where | |---|---| | API + Scalar docs | `https://localhost:7030/scalar` | | Admin app | `http://localhost:5173` | | Dashboard app | `http://localhost:5174` | Log in with one of the seeded [demo accounts](/docs/getting-started/demo-accounts/) — the dashboard login page has a persona picker in dev. ## Next - **[Demo accounts](/docs/getting-started/demo-accounts/)** — the seeded tenants, personas, and what each one demonstrates. - **[Troubleshooting](/docs/getting-started/troubleshooting/)** — fixes for the common first-run failures (containers, volumes, ports, migrations). - **[Install](/docs/getting-started/install/)** — every install path (GitHub template, Codespaces, the CLI, and the `dotnet new` template). - **[Architecture](/docs/architecture/)** — the modular monolith + VSA mental model. - **[CLI](/docs/cli/)** — the `fsh` commands for scaffolding and managing a project. --- # Troubleshooting > Symptom → cause → fix for the failure modes you actually hit on first run and in the daily dev loop. Source: https://fullstackhero.net/docs/getting-started/troubleshooting/ Every entry below is a failure mode someone actually hit, in symptom → cause → fix form. Resource and volume names come straight from `src/Host/FSH.Starter.AppHost/AppHost.cs` — the AppHost derives a per-app prefix from its assembly name, so for a stock clone the prefix is **`fsh-starter`** and the Docker volumes are `fsh-starter-postgres-data`, `fsh-starter-redis-data`, and `fsh-starter-minio-data`. If you renamed the solution, swap the prefix accordingly. ## First run ### React apps die immediately at AppHost launch **Symptom:** the `fsh-starter-admin` / `fsh-starter-dashboard` resources fail right after start; logs show `'vite' is not recognized` (Windows) or `sh: vite: not found`. Aspire launches both apps with `AddJavaScriptApp(...).WithNpm()` running their `dev` script — it does **not** run `npm install` for you. A fresh clone has no `node_modules` in either app. ```bash cd clients/admin && npm install cd ../dashboard && npm install ``` Then relaunch the AppHost. This is a one-time step per clone (and again whenever `package-lock.json` changes). ### Port conflicts **Symptom:** a resource fails to bind, or the browser shows a different app on a familiar port. These are the fixed ports the stack claims. Postgres and Valkey are *not* on this list — Aspire assigns them dynamic host ports (check the Aspire dashboard), so a locally installed Postgres on 5432 won't clash. | Port | What | |---|---| | 7030 / 5030 | API https / http (`launchSettings.json`) | | 5173 | Admin app (Vite) | | 5174 | Dashboard app (Vite) | | 5050 | pgAdmin | | 5540 | RedisInsight | | 9000 / 9001 | MinIO API / console | | 17273 / 15036 | Aspire dashboard https / http | | 4317 | Aspire OTLP ingestion | Find and kill whatever holds a port: ```bash # Windows netstat -ano | findstr :5173 taskkill /PID /F # macOS / Linux lsof -i :5173 kill ``` ## Containers & Aspire The infrastructure containers (`postgres`, its pgAdmin sidecar, `redis` (Valkey), `redis-insight`, `minio`) all use `ContainerLifetime.Persistent` — they survive AppHost shutdown on purpose. That's what makes restarts fast, and it's also the root of the next three entries. ### Postgres exits after an Aspire version bump **Symptom:** the `postgres` container is `Exited (1)`; its log complains about existing data in `/var/lib/postgresql` (an initialized data directory from another major version). The AppHost calls `AddPostgres("postgres")` without pinning an image tag, so the Postgres major version tracks the Aspire package default — Aspire 13.4 moved it to PG 18. Your persistent data volume was initialized by the older major, and Postgres refuses to start on a data directory it can't read. Wipe the container and the volume (this deletes all local data — the migrator recreates and reseeds everything on the next launch): ```bash # Find the container that mounts the volume docker ps -a --filter volume=fsh-starter-postgres-data --format "{{.ID}} {{.Names}} {{.Status}}" docker rm -f docker volume rm fsh-starter-postgres-data ``` Relaunch the AppHost; the DbMigrator reapplies migrations and reseeds. ### Containers orphaned from a deleted Docker network **Symptom:** after a Docker Desktop restart or an Aspire upgrade, containers show `Exited (255)` or hang in *Starting*; logs or `docker inspect` mention `network ... not found`; the API and both React apps sit in *Waiting* forever. Persistent containers outlive the Docker network Aspire created them on. When that network is gone (Docker restarted, Aspire bumped), the container references a network ID that no longer exists and can't start. Data volumes are separate objects — removing the container is safe. ```bash docker ps -a docker rm -f # e.g. the redis / redis-insight / postgres / minio container ``` Relaunch the AppHost; it recreates the containers on a fresh network and reattaches the existing volumes. ### Valkey won't start — RDB written by a newer engine **Symptom:** the `redis` container (it runs Valkey) exits on start; the log complains it can't read the RDB dump (e.g. `Can't handle RDB format version ...`). The `fsh-starter-redis-data` volume holds a dump written by a newer engine version than the one now starting — RDB files are not backward-compatible. This happens when a newer image wrote the volume and you later run an older one. The volume only holds cache data, so wiping it is harmless: ```bash docker rm -f docker volume rm fsh-starter-redis-data ``` ## Database & migrations ### "relation ... does not exist" **Symptom:** API requests fail with Npgsql `42P01: relation "..." does not exist`. The database is **not** migrated at API startup — migrations are applied by the separate DbMigrator. Under the AppHost this is automatic (the API waits for the migrator to complete), so you mainly hit this when running the API standalone against a fresh or out-of-date database. ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed ``` `apply` migrates, `--seed` also seeds the root tenant + admin. Use `-- list-pending` to see what would run without applying anything. ### seed-demo refuses to run **Symptom:** `[demo-seed] REFUSING to run — DOTNET_ENVIRONMENT is 'Production'. seed-demo is dev-only by design.` The demo seeder (acme/globex tenants + demo users) hard-fails outside Development. The DbMigrator is a console host, which reads `DOTNET_ENVIRONMENT` — setting `ASPNETCORE_ENVIRONMENT` alone is ignored. ```bash # PowerShell $env:DOTNET_ENVIRONMENT = "Development"; dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo # bash DOTNET_ENVIRONMENT=Development dotnet run --project src/Host/FSH.Starter.DbMigrator -- seed-demo ``` ## API & configuration ### API refuses to boot in Production — JWT signing key **Symptom:** startup throws `OptionsValidationException` with `No Key defined in JwtOptions config`, `SigningKey must be at least 32 characters long.`, or `SigningKey looks like a sample placeholder ('replace-with-…')`. `JwtOptions` is validated with `ValidateOnStart()`: the signing key must be present, at least 32 characters, and must not contain the `replace-with` sample placeholder — otherwise every token the API issued would be forgeable by anyone with repo access. `appsettings.json` and `appsettings.Production.json` ship with an **empty** `SigningKey` on purpose; only `appsettings.Development.json` has a dev-only default. ```bash # Set a real secret (>= 32 chars) via environment variable JwtOptions__SigningKey="" ``` Locally you can use user-secrets instead; in production use env vars or your secret store. `HangfireOptions` (`UserName` min 3 chars, `Password` min 12) and `CorsOptions` (`AllowedOrigins` required when `AllowAll` is false) are validated at startup the same way — and both are intentionally blank in `appsettings.Production.json`. A Production boot needs all three configured. ### SignalR fails in dev while REST works **Symptom:** REST calls succeed, but the SignalR `/negotiate` request dies with a CORS error in the browser console. SignalR's negotiate always runs **credentialed**, and the CORS spec forbids the `*` wildcard with credentials — so a plain allow-anything policy silently breaks SignalR while ordinary fetches keep working. The kit's policy handles this: with `CorsOptions:AllowAll = true` (the Development default) it echoes the request origin via `SetIsOriginAllowed(_ => true)` + `AllowCredentials()`. If you hit this, your environment isn't using the Development config — either set `AllowAll` to `true` for dev, or list your frontend origin explicitly: ```bash CorsOptions__AllowAll=true # or, for a locked-down config: CorsOptions__AllowedOrigins__0=https://app.example.com ``` Never replace the policy with `AllowAnyOrigin()` — it cannot be combined with credentials and reintroduces exactly this failure. ### Hangfire dashboard prompts or returns 401 **Symptom:** `/jobs` shows a Basic-auth prompt (`Hangfire Dashboard` realm) and rejects your credentials with 401. The dashboard is guarded by a Basic-authentication filter whose credentials come from `HangfireOptions` (`UserName` / `Password`, dashboard route defaults to `/jobs`). Under the AppHost the dev values are `admin` / `Password123!`. There is no safe default — in any other environment you must set them yourself, and startup validation rejects a missing username or a password shorter than 12 characters: ```bash HangfireOptions__UserName=admin HangfireOptions__Password= ``` ## Tests ### Integration tests fail instantly — Docker not running **Symptom:** every test in `Integration.Tests` fails fast with `DockerUnavailableException`. The integration suite runs the real API over Testcontainers (PostgreSQL + MinIO spun up per test run), so a running Docker daemon is a hard requirement. This is environmental, not a regression. ```bash # Start Docker Desktop / the docker daemon, then: dotnet test src/FSH.Starter.slnx ``` If you can't run Docker, run the unit-test projects individually (everything except `Integration.Tests` / `Integration.Middleware.Tests`) — they have no container dependency. --- # Overview > Task-oriented walkthroughs — add a feature, add a module, add a dashboard page, stay current with upstream, run operator impersonation. Source: https://fullstackhero.net/docs/guides/ Step-by-step recipes for the most common things teams do with the kit. --- # Add a page to the tenant dashboard > End-to-end recipe for adding a new page to the tenant dashboard app — API module, page component, lazy route, sidebar nav entry, permission gating, and a route-mocked Playwright test. Source: https://fullstackhero.net/docs/guides/add-a-dashboard-page/ This recipe walks a new screen into `clients/dashboard` end-to-end: the API module, the page component, the lazy route, the sidebar entry, permission gating, and the Playwright test. The running example is a **Projects** list-plus-create page; swap in your own resource. For the surrounding context — app shell, providers, design tokens — see the [frontend architecture](/docs/frontend/architecture/) and [dashboard app](/docs/frontend/dashboard/) pages. The dashboard (tenant-facing) and admin (operator-facing) apps share a stack but differ on purpose: the dashboard hand-rolls forms (no react-hook-form/zod), uses camelCase query params, and has **no per-route permission guard**. If you're adding an admin page instead, the differences are summarized [at the end](#how-the-admin-app-differs). ## Step 1 — API module (`src/api/projects.ts`) Types are **hand-written** — there is no codegen step. One file per feature in `src/api/`, exporting DTO types and thin async functions over `apiFetch` (from `@/lib/api-client`, which handles the bearer token, the `tenant` header, single-flight 401 refresh, and throws `ApiRequestError` on problem-details responses). Condensed from the real `src/api/tickets.ts`: ```ts import { apiFetch } from "@/lib/api-client"; import type { PagedResponse } from "@/api/catalog"; // or re-declare inline export type ProjectDto = { id: string; name: string; description?: string | null; createdAtUtc: string; }; export type CreateProjectInput = { name: string; description?: string | null }; const BASE = "/api/v1"; export function searchProjects(params: { search?: string; pageNumber?: number; pageSize?: number; } = {}): Promise> { const q = new URLSearchParams(); if (params.search) q.set("search", params.search); q.set("pageNumber", String(params.pageNumber ?? 1)); q.set("pageSize", String(params.pageSize ?? 20)); return apiFetch>(`${BASE}/projects?${q.toString()}`); } export function createProject(input: CreateProjectInput): Promise { return apiFetch(`${BASE}/projects`, { method: "POST", body: JSON.stringify({ name: input.name, description: input.description ?? null }), }); } ``` Dashboard conventions to match (they differ from admin): **camelCase** query params (`pageNumber`, `search`), create mutations usually return `Promise` (the new id), and `PagedResponse` is either re-declared inline or imported from an existing api module — there's no central `api-types` file here. ## Step 2 — Page component (`src/pages/projects/projects.tsx`) Pages are **named exports** (no default exports anywhere) and keep `useQuery`/`useMutation` inline. Query keys are hierarchical literal arrays with the params object last; paginated lists use `placeholderData: keepPreviousData`. Condensed from `src/pages/tickets/tickets.tsx`: ```tsx export function ProjectsPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [pageNumber, setPageNumber] = useState(1); useEffect(() => { const id = window.setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => window.clearTimeout(id); }, [search]); useEffect(() => setPageNumber(1), [debouncedSearch]); const query = useQuery({ queryKey: ["projects", "list", { search: debouncedSearch, pageNumber }], queryFn: () => searchProjects({ search: debouncedSearch || undefined, pageNumber }), placeholderData: keepPreviousData, }); // render with EntityPageHeader + the components/list family + components/ui primitives } ``` UI building blocks: - **Primitives** live in `src/components/ui/` — cva-based, shadcn-style (`button`, `card`, `dialog`, `input`, `badge`, `dropdown-menu`, …). List/page furniture (the `Entity*` family like `EntityPageHeader`) lives in `src/components/list/`. - **Tailwind v4 is CSS-first** — no `tailwind.config`; tokens are CSS variables in `src/styles/globals.css`. Don't hard-code colors, and keep dashboard neutrals at chroma 0. - Long lists should use `@tanstack/react-virtual`. ### Forms: hand-rolled, not react-hook-form The dashboard does **not** depend on react-hook-form or zod — that's an admin-only stack. Use controlled inputs with local `useState` and a plain `onSubmit`. List + create typically live in **one file**, with the editor in a `` (see `CreateTicketDialog` in the tickets page): ```tsx function CreateProjectDialog({ open, onClose, onCreated }: Props) { const [name, setName] = useState(""); useEffect(() => { if (open) setName(""); }, [open]); // reset on open const mutation = useMutation({ mutationFn: (input: CreateProjectInput) => createProject(input), onSuccess: () => { toast.success("Project created"); onCreated(); onClose(); }, onError: (err: unknown) => toast.error(describe(err)), // describe() from @/lib/list-helpers }); const onSubmit = (e: FormEvent) => { e.preventDefault(); if (!name.trim()) return; mutation.mutate({ name: name.trim() }); }; //
… } ``` The parent invalidates the list when the dialog reports success: ```tsx setEditor({ mode: "closed" })} onCreated={() => void queryClient.invalidateQueries({ queryKey: ["projects"] })} /> ``` `useMutation` reads its options at execute time. Any value produced at call time — the form payload, a `crypto.randomUUID()` client id, the id of the row being acted on — must be passed **through `mutate(arg)`** and read from the `variables` argument of `onMutate`/`onSuccess`/`onError`. Never read it from component state the callbacks close over: two rapid calls collide, and an `onMutate` that `setState`s the value the next `mutationFn` closes over fires the request with the wrong data. ```tsx mutation.mutate({ text, clientId: crypto.randomUUID() }); // onMutate: ({ clientId }) => insert optimistic `temp:${clientId}` // onSuccess: (real, { clientId }) => swap temp → real // onError: (_e, { clientId }) => rollback ``` ## Step 3 — Register the lazy route (`src/routes.tsx`) Every page is code-split via the `lazyNamed` helper (adapts named exports to `React.lazy`'s default contract) and wrapped in `withSuspense` for a per-route skeleton fallback. Add the child route under `AppShell` — which already sits inside `ProtectedRoute`: ```tsx const ProjectsPage = lazyNamed(() => import("@/pages/projects/projects"), "ProjectsPage"); // inside the AppShell children array: { path: "projects", element: withSuspense() }, ``` There is **no per-route permission guard** in the dashboard — `ProtectedRoute` is auth-only. Don't port admin's `RouteGuard` here; the server returns 403 for anything the user can't do, and the nav hides entries they can't reach (next step). ## Step 4 — Sidebar nav entry (`src/components/layout/nav-data.ts`) Nav is data-driven. Add a `NavSpec` to one of the `sections` (or `topNavTop`/`topNavBottom`), with a lucide icon: ```ts { id: "operations", caption: "Operations", icon: Activity, items: [ // … { to: "/projects", label: "Projects", icon: FolderKanban }, ], }, ``` Two optional gates control visibility: - `perm: "Permissions.Projects.View"` — hidden unless the user holds that exact permission. - `anyPerm: [...]` — visible if the user holds **any** of the listed permissions. Used by the Trash entry, which fronts five independently-gated tabs. `visibleSections()` filters items by the user's permission list and drops sections left empty, so a gated entry never leads to a guaranteed 403. ## Step 5 — Permission gating (nav + actions, not routes) How permissions actually flow in the dashboard (verified in `src/auth/auth-context.tsx`): 1. The JWT carries only role names, **not** permissions. After sign-in (and on every subject change, including impersonation), `AuthProvider` fetches `GET /api/v1/identity/permissions` and caches the list in the token store (`fsh.dashboard.permissions`). 2. Components read it via `useAuth()`: `user.permissions` plus a `permissionsHydrated` flag so gated UI doesn't flash while the fetch is in flight. 3. Permission **strings are mirrored by hand** where the UI needs them, following the server registry convention `Permissions.{Resource}.{Action}`. The pattern to copy is `src/lib/trash-permissions.ts` — a small `as const` map with a comment naming the server permission each value mirrors. Gate the nav entry (step 4) and any in-page actions (hide a Restore button, hide a tab) with `user.permissions.includes(...)`. The server keeps enforcing everything as defence-in-depth — client gating is UX, not security. ## Step 6 — Playwright test (`tests/projects/projects.spec.ts`) Tests are **route-mocked and JWT-seeded** — no real backend. `playwright.config.ts` auto-boots `npm run dev`. The harness (in `tests/helpers/`): - `seedAuthedSession(page, TEST_USER)` writes a fake JWT (junk signature — the client never validates it) into the `fsh.dashboard.*` localStorage keys via `addInitScript`, before React boots. - `installShellMocks(page)` stubs **every call the authenticated AppShell fires on load** — notifications, chat badge, profile, permissions, tenant status — and aborts the SSE/SignalR transports. - `mockJsonResponse` / `mockProblemDetails` (in `api-mocks.ts`) mock individual endpoints; `paged([...])` builds a paged response body. Condensed from `tests/tickets/tickets-list.spec.ts`: ```ts import { expect, test } from "@playwright/test"; import { mockJsonResponse } from "../helpers/api-mocks"; import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed"; import { installShellMocks, paged } from "../helpers/shell-mocks"; test.beforeEach(async ({ page }) => { await seedAuthedSession(page, TEST_USER); await installShellMocks(page); // Page-specific mocks AFTER shell mocks — Playwright matches the most // recently registered route first, so these win over the broad defaults. await mockJsonResponse(page, "**/api/v1/projects**", paged([SAMPLE_PROJECT])); }); test("renders the list", async ({ page }) => { await page.goto("/projects"); await expect(page.getByRole("heading", { name: /projects/i })).toBeVisible(); }); ``` Mock everything the page touches when it mounts — not just the endpoint you're asserting on. An unmocked call hangs or 401s, and a 401 triggers the client's refresh-then-logout path, bouncing the test to `/login` at a timing that depends on elapsed time. That's the classic source of "flaky only sometimes" specs. If your page opens a dialog that searches users, mock `**/api/v1/identity/users/search**` too, even if the test never opens it. Two more harness gotchas, both verified in the existing suite: - **Permission-gated UI**: the shell mock returns `[]` for `GET /identity/permissions`, so gated entries are hidden by default. Re-mock it with the permissions your test needs (see `tests/system/trash.spec.ts`). - **Strict-mode double match**: list pages render a mobile card list and a desktop table of the same data. Scope assertions to one of them (the tickets spec filters to the desktop card) or `getByText` will find two elements. ## Step 7 — Verify ```bash cd clients/dashboard && npm run lint && npm run test:e2e ``` ## How the admin app differs Same skeleton, different conventions — don't cross-pollinate: - **Routes are permission-gated**: admin wraps elements in `` instead of `withSuspense` alone, and every permission is mirrored as a constant in `src/lib/permissions.ts`. - **Forms use react-hook-form + zod** (`useForm` + `zodResolver`), not controlled state. - **Query params are PascalCase** (`PageNumber`, `Search`), and `PagedResponse` imports from `@/lib/api-types`. - **List and create are separate routed pages** (`list.tsx`, `create.tsx`), not one file with dialogs. See the [admin app reference](/docs/frontend/admin/) for the full set. ## Checklist 1. API module in `src/api/{feature}.ts` — hand-written types, `apiFetch`, camelCase params, `PagedResponse` inline or imported from an existing module. 2. Page in `src/pages/{area}/{name}.tsx` — **named export**; hierarchical query key with params object last; `placeholderData: keepPreviousData`. 3. Mutations pass per-call data through `mutate(arg)`; invalidate the list key in `onSuccess`; hand-rolled controlled form in a ``. 4. Route registered in `src/routes.tsx` via `lazyNamed` + `withSuspense`, under `AppShell` — no route-level permission guard. 5. Nav entry in `src/components/layout/nav-data.ts`, gated with `perm`/`anyPerm` if the server endpoint requires a permission; mirror the permission string `Permissions.{Resource}.{Action}` style (pattern: `src/lib/trash-permissions.ts`). 6. Playwright spec in `tests/{area}/{name}.spec.ts` — `seedAuthedSession` + `installShellMocks` first, page mocks after; every on-load endpoint mocked; assertions scoped against the mobile/desktop double render. 7. `npm run lint && npm run test:e2e` green. ## Related - [Frontend architecture](/docs/frontend/architecture/) — shared stack, API client, runtime config, providers. - [Dashboard app](/docs/frontend/dashboard/) — tenant app divergences: SSE, impersonation, theming. - [Frontend development](/docs/frontend/development/) — dev server, proxy, testing workflow. - **AI route**: point your coding agent at `.agents/skills/add-react-page/SKILL.md` in the repo — it scaffolds this exact recipe for either app. --- # Add a feature to an existing module > End-to-end recipe for adding a new command/query slice to an existing module — command, handler, validator, endpoint, wiring, permission, and tests, walked through with a concrete Catalog example. Source: https://fullstackhero.net/docs/guides/add-a-feature/ Adding a command or query slice to a module that already exists is the single most common task in this codebase. This guide walks it end-to-end with a concrete example — **archiving a product** in the Catalog module — where every code shape mirrors a real slice that ships with the kit (`ChangeProductPrice` in `src/Modules/Catalog`). By the end you'll have: a command in the Contracts project, a handler + validator + endpoint in the runtime project, a permission gate, and tests. No DI registration, no MediatR-style manual wiring — the module loader and source-generated Mediator discover everything by convention. If you work with an AI coding tool, the repo ships a step-by-step recipe at `.agents/skills/add-feature/SKILL.md` that scaffolds exactly this slice. Point your agent at it ("use the add-feature skill to add X to the Catalog module") and review the diff against this guide. See [AI-assisted development](/docs/ai-development/). ## Where the pieces go A feature is a vertical slice **split across two projects**. The request/response types live in the module's `.Contracts` project — that's the module's only public API, and the only thing other modules may reference. The handler, validator, and endpoint live in the runtime project, grouped in one folder per feature: ``` src/Modules/Catalog/ ├── Modules.Catalog.Contracts/ │ ├── v1/Products/ArchiveProductCommand.cs # the command (public API) │ └── Dtos/ProductDto.cs # response DTOs, if any └── Modules.Catalog/ ├── Domain/Product.cs # entity behavior lives here └── Features/v1/Products/ArchiveProduct/ ├── ArchiveProductCommandHandler.cs # public sealed, injects the DbContext ├── ArchiveProductCommandValidator.cs # required — enforced by Architecture.Tests └── ArchiveProductEndpoint.cs # internal static extension method ``` Why the split matters: [module boundaries](/docs/architecture/vertical-slice/) are enforced by `Architecture.Tests` — a module referencing another module's runtime project fails the build. ## Step 1 — The command (Contracts project) Commands and queries are records implementing the `Mediator` interfaces (`using Mediator;` — this is the source-generated [Mediator](https://github.com/martinothamar/Mediator) library, not MediatR). ```csharp // Modules.Catalog.Contracts/v1/Products/ArchiveProductCommand.cs using Mediator; namespace FSH.Modules.Catalog.Contracts.v1.Products; public sealed record ArchiveProductCommand(Guid ProductId) : ICommand; ``` Queries implement `IQuery` instead. Paginated queries implement `IPagedQuery` and return `PagedResponse` from `FSH.Framework.Shared.Persistence`. Response DTOs go in `Contracts/Dtos/`. ## Step 2 — Entity behavior (and a domain event, if it matters) Business rules belong on the aggregate, not in the handler. `Product` already follows this pattern — `ChangePrice` and `AdjustStock` are methods on the entity that guard invariants and raise domain events. Add `Archive` the same way: ```csharp // Modules.Catalog/Domain/Product.cs public void Archive() { if (!IsActive) return; IsActive = false; UpdatedAtUtc = DateTime.UtcNow; AddDomainEvent(DomainEvent.Create((id, ts) => new ProductArchivedDomainEvent(Id, id, ts))); } ``` The event is a record in `Domain/Events/`, mirroring `ProductPriceChangedDomainEvent`: ```csharp public sealed record ProductArchivedDomainEvent( Guid ProductId, Guid EventId, DateTimeOffset OccurredOnUtc) : DomainEvent(EventId, OccurredOnUtc); ``` Domain events are published automatically after `SaveChangesAsync` by the framework's `DomainEventsInterceptor` — any `INotificationHandler` for the event in the same module just works. Skip the event if nothing reacts to the change; it's signal, not ceremony. (Cross-module communication uses integration events via the Outbox instead — different mechanism, see the eventing rules.) ## Step 3 — The handler (runtime project) There is **no generic `IRepository`** in this kit. Handlers inject the module's DbContext directly. The shape — copied from the real `ChangeProductPriceCommandHandler` — is: `public sealed`, primary constructor, `ValueTask`, `.ConfigureAwait(false)` on every await, guard clause first. ```csharp // Features/v1/Products/ArchiveProduct/ArchiveProductCommandHandler.cs public sealed class ArchiveProductCommandHandler(CatalogDbContext dbContext) : ICommandHandler { public async ValueTask Handle(ArchiveProductCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); var product = await dbContext.Products .FirstOrDefaultAsync(p => p.Id == command.ProductId, cancellationToken) .ConfigureAwait(false) ?? throw new NotFoundException($"Product {command.ProductId} not found."); product.Archive(); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return product.Id; } } ``` A few things you get for free here: - **Tenant isolation** — `CatalogDbContext` extends `BaseDbContext`, so the query above is automatically scoped to the current tenant. You never filter by tenant id manually. - **Audit stamping** — created/modified fields are stamped by interceptors. Only inject `ICurrentUser` if you need the acting user in business logic. - **Error mapping** — throw `NotFoundException` (404), `ForbiddenException` (403), `UnauthorizedException` (401), or `CustomException(msg, errors, statusCode)` from `FSH.Framework.Core.Exceptions`; the global exception handler converts them to RFC 9457 ProblemDetails. Never return error objects from handlers. The handler needs no registration — Mediator's source generator discovers it as long as the module's assemblies are listed in `Program.cs` (they already are, since the module exists). See [dependency injection](/docs/architecture/dependency-injection/) for how `ModuleLoader` wires the rest. ## Step 4 — The validator (not optional) Every command handler and every paginated-query handler must have a `{Name}Validator` in the same feature folder. This is enforced by `Architecture.Tests` (`HandlerValidatorPairingTests`) — skip it and the test suite fails. ```csharp // Features/v1/Products/ArchiveProduct/ArchiveProductCommandValidator.cs public sealed class ArchiveProductCommandValidator : AbstractValidator { public ArchiveProductCommandValidator() { RuleFor(x => x.ProductId).NotEmpty(); } } ``` FluentValidation validators are auto-registered by the module loader and run in the `ValidationBehavior<,>` Mediator pipeline *before* the handler — a failing rule short-circuits into a 400 ProblemDetails response. The handler can assume the command is shape-valid. ## Step 5 — The endpoint Endpoints are `internal static` extension methods on `IEndpointRouteBuilder` that delegate to Mediator. Always accept and forward the `CancellationToken` — ASP.NET injects it from the request. ```csharp // Features/v1/Products/ArchiveProduct/ArchiveProductEndpoint.cs public static class ArchiveProductEndpoint { internal static RouteHandlerBuilder MapArchiveProductEndpoint(this IEndpointRouteBuilder endpoints) => endpoints.MapPost("/products/{productId:guid}/archive", async (Guid productId, IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new ArchiveProductCommand(productId), ct))) .WithName("ArchiveProduct") .WithSummary("Archive a product") .RequirePermission(CatalogPermissions.Products.Update); } ``` When the request has a body *and* a route parameter, merge them the way `ChangeProductPriceEndpoint` does — bind the body, then overwrite the id from the route so the URL wins: ```csharp endpoints.MapPatch("/products/{productId:guid}/price", async (Guid productId, ChangeProductPriceCommand body, IMediator mediator, CancellationToken ct) => { var command = body with { ProductId = productId }; return Results.Ok(await mediator.Send(command, ct)); }) ``` Add `.WithIdempotency()` on POSTs that must be replay-safe (creates, payment-ish operations). Archiving is naturally idempotent, so it doesn't need it. ### Permissions: reuse or add Permission constants live in the module's Contracts project — `Modules.Catalog.Contracts/Authorization/CatalogPermissions.cs` — in the format `Permissions.{Resource}.{Action}` (e.g. `Permissions.Catalog.Products.Update`). Archiving is an update-shaped operation, so reusing `Products.Update` is fine. If the operation deserves its own grant (the kit did exactly this for `AdjustStock`), add two lines to `CatalogPermissions`: ```csharp public const string Archive = $"Permissions.{Resource}.Archive"; // in Products new("Archive Products", "Archive", Products.Resource), // in the All list ``` The module already calls `PermissionConstants.Register(CatalogPermissions.All)` in `ConfigureServices`, so the new permission is known to the platform immediately. The Admin role picks it up as a role claim on the next seed run (`dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed`) — existing claims are untouched, missing ones are added. ## Step 6 — Wire it into the module One line in `CatalogModule.MapEndpoints()`, on the existing versioned group: ```csharp // CatalogModule.cs — group is endpoints.MapGroup("api/v{version:apiVersion}/catalog")… group.MapArchiveProductEndpoint(); ``` Watch the registration order if your route shares a prefix with a catch-all: literal segments like `/products/trash` must be mapped *before* `/products/{id:guid}` so they win. `/products/{productId:guid}/archive` has no such conflict. That's the whole wiring. No DI registration, no `Program.cs` changes — those are only needed when adding a whole new *module*. ## Step 7 — Tests Two layers, both with real examples to copy. **Unit-test the domain behavior** in `src/Tests/Catalog.Tests/Domain/ProductTests.cs` style — plain xUnit + Shouldly, asserting state and the raised event: ```csharp [Fact] public void Archive_Should_DeactivateAndRaiseEvent_When_Active() { Product product = CreateValidProduct(); product.ClearDomainEvents(); // drop the creation event product.Archive(); product.IsActive.ShouldBeFalse(); product.DomainEvents.ShouldHaveSingleItem() .ShouldBeOfType(); } ``` **Integration-test the endpoint** in `src/Tests/Integration.Tests/Tests/Catalog/` — the harness spins up the real API against Testcontainers (Docker required). Copy the `ProductsEndpointTests` pattern: the shared collection fixture plus `AuthHelper` for an authenticated client. ```csharp [Collection(FshCollectionDefinition.Name)] public sealed class ProductsEndpointTests(FshWebApplicationFactory factory) { private readonly AuthHelper _auth = new(factory); [Fact] public async Task ArchiveProduct_Should_DeactivateProduct() { using var client = await _auth.CreateRootAdminClientAsync(); var productId = await CreateAsync(client, /* …seed a product… */); using var response = await client.PostAsync( $"{TestConstants.CatalogBasePath}/products/{productId}/archive", null); response.StatusCode.ShouldBe(HttpStatusCode.OK); var updated = await GetAsync(client, productId); updated.IsActive.ShouldBeFalse(); } } ``` Cover the permission gate too — the existing suite asserts that a Basic user gets 403 on mutating endpoints. Full conventions: [writing new tests](/docs/testing/writing-new-tests/). ## Verify ```bash dotnet build src/FSH.Starter.slnx # must be 0 warnings — TreatWarningsAsErrors is on dotnet test src/FSH.Starter.slnx # integration tests require Docker ``` The build failing on a missing validator or a boundary violation is `Architecture.Tests` doing its job. ## Do I need a migration? **Not for this feature** — `IsActive` already exists on `Product`. You only need a migration when you change the *schema*: a new entity, a new column, an index, a renamed property. The signal is EF model changes in `Domain/` or `Data/Configurations/`. When you do, migrations live in the dedicated `FSH.Starter.Migrations.PostgreSQL` project and are applied by the DbMigrator, never at API startup — follow [database migrations](/docs/deployment/database-migrations/). ## Checklist 1. Command/query record in `Modules.{X}.Contracts/v1/{Area}/` (`using Mediator;`); DTOs in `Contracts/Dtos/`. 2. Business rule as a method on the entity; domain event if something reacts to it. 3. Handler in `Modules.{X}/Features/v1/{Area}/{Feature}/` — `public sealed`, injects the module DbContext, `ValueTask`. 4. `{Name}Validator` in the same folder. 5. Endpoint in the same folder — `.WithName`, `.WithSummary`, `.RequirePermission(...)`. 6. New permission constant + `All` entry, if the action deserves its own grant. 7. One line in `{X}Module.MapEndpoints()`. 8. Domain unit test + integration endpoint test (incl. the 403 case). 9. Build with zero warnings; suite green. 10. Migration only if the schema changed. ## Golden rules that bite These are the conventions that fail builds or — worse — fail silently: - **Handlers must be `public sealed`.** The Mediator source generator and `Architecture.Tests` both check this. An `internal` handler is silently undiscovered: the endpoint compiles, then 500s at runtime. - **The validator is mandatory.** Command handlers and paginated-query handlers without a `{Name}Validator` fail `Architecture.Tests`. - **Propagate `CancellationToken` everywhere** — endpoint delegate → `mediator.Send` → every EF/IO call. `.ConfigureAwait(false)` on every await in handlers. - **Structured logging only.** Message templates (`logger.LogInformation("Archived product {ProductId}", id)`), never string interpolation — analyzers treat it as an error. - **Don't reach into another module's runtime project.** If your feature needs another module's data, go through its `.Contracts` interfaces or an integration event. ## Related - [Architecture: vertical slices](/docs/architecture/vertical-slice/) — why features are shaped this way. - [Architecture: dependency injection](/docs/architecture/dependency-injection/) — how modules, handlers, and validators get registered. - [Testing: writing new tests](/docs/testing/writing-new-tests/) — unit + integration conventions in depth. - [Deployment: database migrations](/docs/deployment/database-migrations/) — when your feature does change the schema. - [AI-assisted development](/docs/ai-development/) — the `.agents/skills/add-feature` recipe your coding agent can run. --- # Add a module > End-to-end recipe for creating a new bounded-context module — the two-project layout, IModule and FshModule registration, BaseDbContext, migrations, and the four wire points people miss. Source: https://fullstackhero.net/docs/guides/add-a-module/ A module is a bounded context: a runtime project (internal — handlers, endpoints, domain, data) plus a `.Contracts` project (its only public API — commands, queries, DTOs, events, permissions). Other modules may reference the Contracts project, never the runtime project. That boundary is enforced by architecture tests, so wiring it wrong fails loudly. What does **not** fail loudly is registration. A module must be wired in **four** places across two `Program.cs` files, and missing any one of them produces a host that boots green and a module that silently doesn't work. That's the part this guide exists for — everything else is mechanical. Before you start: if you're adding a feature to an *existing* module, this is the wrong page — see [Add a feature](/docs/guides/add-a-feature/). And if you're working with an AI coding agent, the repo ships a scaffolding recipe at `.agents/skills/add-module/` that encodes everything below; pointing your agent at it is the fastest route. This guide builds a fictional `Inventory` module and uses the real `Notifications` module (`src/Modules/Notifications/`) as the reference implementation throughout — it's the smallest complete module in the kit. ## 1. Create the two projects ```text src/Modules/Inventory/ ├── Modules.Inventory/ ← runtime (internal) │ ├── AssemblyInfo.cs ← [assembly: FshModule(...)] lives here │ ├── InventoryModule.cs ← IModule implementation │ ├── Domain/ │ ├── Data/ ← DbContext, configurations, initializer │ └── Features/v1/ ← vertical slices └── Modules.Inventory.Contracts/ ← public API ├── InventoryContractsMarker.cs ├── Authorization/ ← permissions └── v1/ ← Commands/, Queries/, DTOs/ ``` Don't hand-write the `.csproj` files — copy them from an existing module and rename. The Notifications pair shows the shape: ```xml FSH.Modules.Inventory FSH.Modules.Inventory ``` ```xml FSH.Modules.Inventory.Contracts FSH.Modules.Inventory.Contracts ``` The Contracts project stays deliberately thin: `Mediator.Abstractions` plus shared contracts (`Shared`, and `Eventing.Abstractions` if it publishes integration events). No EF Core, no FluentValidation, no module internals — `ContractsPurityTests` checks all of that. Add a marker class to the Contracts project. It exists purely so `Program.cs` can point the Mediator source generator at the assembly: ```csharp namespace FSH.Modules.Inventory.Contracts; /// /// Marker referenced by Program.cs::AddMediator(o => o.Assemblies = [...]) so the /// Mediator source generator scans this assembly for ICommand/IQuery records. /// public abstract class InventoryContractsMarker; ``` ## 2. Add to the solution and reference from the hosts ```bash dotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory/Modules.Inventory.csproj dotnet sln src/FSH.Starter.slnx add src/Modules/Inventory/Modules.Inventory.Contracts/Modules.Inventory.Contracts.csproj ``` Then make the runtime project reachable from both hosts. Reference it from `src/Host/FSH.Starter.Migrations.PostgreSQL/FSH.Starter.Migrations.PostgreSQL.csproj` — both `FSH.Starter.Api` and `FSH.Starter.DbMigrator` reference the Migrations project, so your module flows to them transitively. Most existing modules are *also* referenced directly from both host `.csproj` files; the direct reference costs nothing and makes the dependency explicit, so follow suit. ## 3. Implement `IModule` and declare it with `[FshModule]` `[FshModule]` is an **assembly-level** attribute, not a class attribute, and its order is a positional argument. Notifications puts it in `AssemblyInfo.cs`: ```csharp // src/Modules/Notifications/Modules.Notifications/AssemblyInfo.cs using FSH.Framework.Web.Modules; [assembly: FshModule(typeof(FSH.Modules.Notifications.NotificationsModule), 750)] ``` `ModuleLoader.AddModules` (in `BuildingBlocks/Web/Modules/ModuleLoader.cs`) discovers these attributes from the `moduleAssemblies` array, orders by `Order` (lower loads first), instantiates each module, and calls `ConfigureServices`. The orders currently in use: | Module | Order | | Module | Order | |---|---|---|---|---| | Identity | 100 | | Billing | 500 | | Multitenancy | 200 | | Catalog | 600 | | Auditing | 300 | | Tickets | 700 | | Files | 350 | | Notifications | 750 | | Webhooks | 400 | | Chat | 800 | Pick something after the modules yours depends on — `900` is a safe default for a new leaf module. Order matters more than it looks: Notifications sits at 750 *deliberately* so its integration-event handlers register before Chat (800) starts publishing. The module class itself, condensed from `NotificationsModule.cs`: ```csharp namespace FSH.Modules.Inventory; public sealed class InventoryModule : IModule { public void ConfigureServices(IHostApplicationBuilder builder) { ArgumentNullException.ThrowIfNull(builder); PermissionConstants.Register(InventoryPermissions.All); builder.Services.AddHeroDbContext(); builder.Services.AddScoped(); builder.Services.AddHealthChecks() .AddDbContextCheck(name: "db:inventory"); } public void MapEndpoints(IEndpointRouteBuilder endpoints) { ArgumentNullException.ThrowIfNull(endpoints); var versionSet = endpoints.NewApiVersionSet() .HasApiVersion(new ApiVersion(1)) .ReportApiVersions() .Build(); var group = endpoints.MapGroup("api/v{version:apiVersion}/inventory") .WithTags("Inventory") .WithApiVersionSet(versionSet) .RequireAuthorization(); // group.MapCreateItemEndpoint(); … } } ``` `IModule` also has a `ConfigureMiddleware(IApplicationBuilder app)` member with a no-op default implementation — only override it if your module needs request middleware (it runs *after* `UseAuthentication`, so claims are available). Validators need no per-module registration: `ModuleLoader.AddModules` calls `AddValidatorsFromAssemblies` over the whole `moduleAssemblies` array. If the module publishes or consumes integration events, also add `AddEventingForDbContext()` and `AddIntegrationEventHandlers(typeof(InventoryModule).Assembly)` — see the eventing rules in `.agents/rules/eventing.md`. ## 4. Permissions Permissions live in the Contracts project under `Authorization/`, follow the `Permissions.{Resource}.{Action}` shape, and expose an `All` list that `ConfigureServices` registers. From `NotificationPermissions.cs`: ```csharp namespace FSH.Modules.Inventory.Contracts.Authorization; public static class InventoryPermissions { public static class Items { public const string Resource = "Inventory.Items"; public const string View = $"Permissions.{Resource}.View"; public const string Create = $"Permissions.{Resource}.Create"; } public static IReadOnlyList All { get; } = [ new("View Inventory", ActionConstants.View, Items.Resource), new("Create Inventory", ActionConstants.Create, Items.Resource), ]; } ``` ## 5. DbContext — subclass `BaseDbContext`, call `base.OnModelCreating` last Every module gets its own schema and its own DbContext extending `BaseDbContext`. Tenant isolation is **default-on**: `BaseDbContext.OnModelCreating` auto-applies the tenant query filter (and soft-delete filter) to every entity it sees, so your subclass must call it **last**, after all entity configuration, or late-configured entities escape the filter. Verbatim from `NotificationsDbContext.cs`, renamed: ```csharp namespace FSH.Modules.Inventory.Data; public sealed class InventoryDbContext : BaseDbContext { public const string Schema = "inventory"; public InventoryDbContext( IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, IOptions settings, IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { } public DbSet Items => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { ArgumentNullException.ThrowIfNull(modelBuilder); modelBuilder.HasDefaultSchema(Schema); modelBuilder.ApplyConfigurationsFromAssembly(typeof(InventoryDbContext).Assembly); base.OnModelCreating(modelBuilder); // MUST be last — applies tenant + soft-delete filters } } ``` If an entity genuinely must span tenants, mark it `IGlobalEntity` to opt out — that's the only sanctioned escape hatch, and `TenantIsolationTests` checks every entity is one or the other. ## 6. Register in all four places This is the step people miss, and every miss is silent. A new module must be registered in **four** places: the Mediator `o.Assemblies` list and the `moduleAssemblies` array in `src/Host/FSH.Starter.Api/Program.cs`, **and the identical pair** in `src/Host/FSH.Starter.DbMigrator/Program.cs`. Miss the Mediator markers and the host boots fine, endpoints map, and `mediator.Send` fails at runtime because the source generator never scanned your assemblies — handlers are silently undiscovered. Miss the `moduleAssemblies` entry and the module simply never loads. Miss the DbMigrator pair and `apply`/`seed` silently skips your module — its migrations never run, its schema never exists. The Mediator list needs **two** markers per module — one type from the Contracts assembly and one from the runtime assembly, because commands live in Contracts and handlers live in the runtime project. From the real `FSH.Starter.Api/Program.cs`, condensed: ```csharp builder.Services.AddMediator(o => { o.ServiceLifetime = ServiceLifetime.Scoped; o.Assemblies = [ // …existing pairs… typeof(FSH.Modules.Catalog.Contracts.CatalogContractsMarker), typeof(FSH.Modules.Catalog.CatalogModule), typeof(FSH.Modules.Notifications.Contracts.v1.Commands.MarkNotificationReadCommand), typeof(FSH.Modules.Notifications.NotificationsModule), typeof(FSH.Modules.Inventory.Contracts.InventoryContractsMarker), // ← add typeof(FSH.Modules.Inventory.InventoryModule)]; // ← add }); var moduleAssemblies = new Assembly[] { // …existing modules… typeof(FSH.Modules.Notifications.NotificationsModule).Assembly, typeof(FSH.Modules.Inventory.InventoryModule).Assembly, // ← add }; ``` (Some older entries use a real command type instead of a dedicated marker class — either works; any type from the right assembly does. New modules use the marker convention.) Now open `src/Host/FSH.Starter.DbMigrator/Program.cs` and make the **same two edits**. The migrator mirrors the API's Mediator registration and module list on purpose — its comment says why: module `IDbInitializer`s depend on services the module wiring provides, and the per-tenant migration pass only iterates modules it loaded. The fastest sanity check after wiring: build, run, hit one of your endpoints, and confirm the handler actually executes. ## 7. Migrations All EF migrations live in **one** project — `src/Host/FSH.Starter.Migrations.PostgreSQL` — foldered per module, each folder with its own model snapshot: ```text src/Host/FSH.Starter.Migrations.PostgreSQL/ ├── Audit/ ├── Catalog/ ├── Notifications/ │ ├── 20260512204534_InitialNotifications.cs │ └── NotificationsDbContextModelSnapshot.cs └── … ``` Add an `Inventory/` folder (and a matching `` entry in the Migrations `.csproj`, like the existing ones), make sure the runtime project is referenced from the Migrations project (step 2), then create the initial migration. **Build first** — `dotnet ef migrations add` reads the compiled snapshot, so generating against a stale build silently drops your latest model changes. The same footgun applies in reverse: `migrations remove` rewrites the snapshot, so only ever remove the latest migration and rebuild before the next `add`. ```bash dotnet tool restore # dotnet-ef is pinned in .config/dotnet-tools.json dotnet build src/FSH.Starter.slnx # ALWAYS build before migrations add dotnet ef migrations add InitialInventory \ --project src/Host/FSH.Starter.Migrations.PostgreSQL \ --startup-project src/Host/FSH.Starter.Api \ --context InventoryDbContext \ --output-dir Inventory ``` All three of `--project`, `--startup-project`, and `--context` are required — there are ten DbContexts in that project, and `--output-dir` is what lands the files in your module's folder. Apply through the DbMigrator (the database is *not* migrated at API startup): ```bash dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending # preview dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply ``` More on the migrator's verbs and deployment story in [database migrations](/docs/deployment/database-migrations/). ## 8. Tests — and what fails if you wired it wrong Add a per-module unit test project following the existing convention: `src/Tests/Inventory.Tests` (see `Catalog.Tests`, `Billing.Tests`, `Chat.Tests` for the shape), and add it to the solution. If your handlers test internal types, the runtime module grants `[assembly: InternalsVisibleTo("Inventory.Tests")]` in its `AssemblyInfo.cs` — Notifications does exactly this. `src/Tests/Architecture.Tests` is the safety net for this whole guide. The rules that bite module authors: | Test | Catches | |---|---| | `ModuleArchitectureTests.Modules_Should_Not_Depend_On_Other_Modules` | A `` from your runtime project to another module's runtime project (csproj scan — only `.Contracts` references are allowed) | | `ContractsPurityTests.*` | EF Core, FluentValidation, Hangfire, DbContexts, or module internals leaking into your Contracts project | | `HandlerValidatorPairingTests.CommandHandlers_Should_Have_Corresponding_Validators` | A command handler without a `{Command}Validator` | | `HandlerValidatorPairingTests.QueryHandlers_With_Pagination_Should_Have_Validators` | A paginated query handler without a validator | | `TenantIsolationTests.BaseDbContext_Entities_Should_Be_TenantIsolated_Or_Marked_Global` | An entity that is neither tenant-isolated nor explicitly `IGlobalEntity` | | `EndpointConventionTests.*` | Endpoint classes that aren't static, misnamed, or missing a `Map*` method | Run the full gauntlet: ```bash dotnet build src/FSH.Starter.slnx # TreatWarningsAsErrors — 0 warnings dotnet test src/Tests/Architecture.Tests dotnet test src/FSH.Starter.slnx # integration tests require Docker ``` ## Checklist 1. Two projects under `src/Modules/Inventory/` — csproj copied from an existing module, runtime references Contracts + BuildingBlocks, Contracts stays thin. 2. `InventoryContractsMarker` class in the Contracts project. 3. Both projects added to `src/FSH.Starter.slnx`; runtime referenced from `FSH.Starter.Migrations.PostgreSQL` (and directly from both hosts, matching existing modules). 4. `[assembly: FshModule(typeof(InventoryModule), 900)]` — assembly-level, positional order. 5. `InventoryModule : IModule` — `PermissionConstants.Register`, `AddHeroDbContext`, `IDbInitializer`, health check, versioned endpoint group. 6. `InventoryPermissions` in `Contracts/Authorization` with an `All` list. 7. `InventoryDbContext : BaseDbContext` — 4-arg ctor, `HasDefaultSchema`, `base.OnModelCreating` **last**. 8. **Registered in all four places** — Mediator pair + `moduleAssemblies` in `FSH.Starter.Api/Program.cs` *and* `FSH.Starter.DbMigrator/Program.cs`. 9. `Inventory/` migrations folder + initial migration (`--context InventoryDbContext --output-dir Inventory`), applied via DbMigrator. 10. `Inventory.Tests` project; build + `Architecture.Tests` + full suite green. 11. Hit an endpoint and confirm a handler actually runs — the four-wire-points check no test performs for you. ## Related - [Architecture: modular monolith](/docs/architecture/modular-monolith/) — why modules are shaped this way. - [Architecture: dependency injection](/docs/architecture/dependency-injection/) — `ModuleLoader`, platform composition, lifetimes. - [Deployment: database migrations](/docs/deployment/database-migrations/) — the DbMigrator in CI/CD. - [Add a feature](/docs/guides/add-a-feature/) — the slice-level recipe once your module exists. - AI route: point your coding agent at `.agents/skills/add-module/SKILL.md` in the repo — it encodes this entire recipe as a scaffolding script. --- # Operator impersonation walkthrough > End-to-end guide to using operator impersonation from the admin console — starting a grant, acting as the user, ending or revoking, reading the audit trail. Source: https://fullstackhero.net/docs/guides/operator-impersonation/ This guide walks through impersonation end-to-end — from the SuperAdmin clicking "Impersonate" in the admin console, through the impersonated session, to the audit trail and revocation. For the underlying mechanics — grant aggregate, jti revocation list, IGlobalEntity persistence — see the [security reference](/docs/security/impersonation/). For the cross-tenant root-operator override, see the [multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/). Impersonation grants the actor full access to the impersonated user's data. Every start / end / revoke is captured by the Auditing module — that's the compliance artefact. Pair it with team policy: impersonate to investigate, not to explore. Document a reason on every grant. ## When to impersonate The legitimate uses: - **Reproduce a user-reported bug** that depends on their data, role, or tenant context. "I can't see the audit log" — impersonate to verify. - **Validate a fix** before telling the user "try again". Avoids "works on my machine" theater. - **Recover an account** when the user can't access their own data and you need to make a change on their behalf. - **Onboard a new tenant** by impersonating their admin user during initial setup. What it's **not** for: - Routine UX testing — use a seeded test user instead. - Looking up data on a user without their consent. - Bypassing your own permission system. The token carries the impersonated user's permissions, so it can't reach anything they couldn't already. ## End-to-end walkthrough ### 1. Open the admin console The admin console lives at `clients/admin/` and runs (by default) at `http://localhost:5173` in dev or your production admin domain. Sign in as a SuperAdmin (a user with the `Identity.Impersonation.Start` permission). For impersonation to work cross-tenant, the SuperAdmin's tenant should be `root`. ### 2. Find the target user Navigate to **Users → All users**. The list shows every user across every tenant if you're a SuperAdmin; otherwise scoped to your own tenant. Impersonation itself is launched from the **tenant's** detail page — open **Tenants**, pick the tenant the user belongs to, and click **Impersonate user** (the action is gated by `Identity.Impersonation.Start`). The dialog carries its own user search, so you select the exact target there. ### 3. Start a grant In the **Impersonate user** dialog, search for and select the target user, then set: - **Duration** — 10 / 15 / 30 minutes (configurable). 30 minutes is the default; pick the shortest window that fits the investigation. - **Reason** — mandatory free text. "Investigating ticket #4127 — user can't upload invoice PDF." This text lands in the audit trail; future you will appreciate the specifics. Click **Start**. The admin console: 1. Calls `POST /api/v1/identity/impersonation/start` with the form payload. 2. Receives the impersonation access token in the response. 3. Opens a new tab pointed at the **tenant dashboard** (or wherever the impersonated user's home is), authenticated with the impersonation token + `tenant: ` header. You're now seeing what the user sees. ### 4. Act as the user The new tab behaves exactly as if the user signed in themselves. Same dashboard, same permissions, same data. There's no UI badge by default — that's deliberate; you're testing the user's experience, not a "you are impersonating!" wrapper. The kit doesn't ship a banner because some debug scenarios need an authentic-user experience. If you'd rather your operators always see one, decode the JWT client-side, check for `act` / impersonation claims, and render a yellow strip at the top of the layout. ~20 LoC in the dashboard's `App.tsx`. ### 5. End the session When you're done, three ways to end the grant: - **Click "End impersonation"** in the impersonated dashboard tab (cleanest) — the banner at the top of that tab carries the button. - **Close the impersonated tab** and ignore the token — the grant stays active until it expires; safe but messy. - **Let it expire** naturally after the duration ends; the token stops working at that instant. The kit's recommended pattern is option 1 — explicit termination via the API: ```http POST /api/v1/identity/impersonation/end Authorization: Bearer ``` …which sets `EndedAtUtc` on the grant. The dashboard fires this automatically when you click **End impersonation** in the impersonation banner. ### 6. Revoke (emergency kill) If you need to kill an in-progress impersonation immediately — the operator left it open and walked away, the operator is no longer trusted, something went sideways — go to **Impersonation → Active grants** and revoke. Click the **Revoke** action; supply an optional reason; confirm. The grant transitions to `Revoked`, the audit trail captures who revoked it and why, and the next request the impersonation token tries to make fails with 401. ### 7. Read the audit trail Navigate to **Audits** and filter by the **Security** event type — impersonation actions (`ImpersonationStarted`, `ImpersonationEnded`, `ImpersonationRevoked`) are recorded as security events. Click any row to open its detail sheet — the payload carries every field the grant captured. ## Cross-tenant impersonation A SuperAdmin in the `root` tenant can impersonate a user in any other tenant. The flow is identical to the single-tenant case from the SuperAdmin's perspective — the admin console figures out the cross-tenant case automatically. Under the hood: 1. The impersonation grant is `IGlobalEntity` — it lives outside the tenant filter, so the SuperAdmin can query / create grants regardless of the current tenant context. 2. The minted access token carries the impersonated user's tenant in the `tenant` claim. 3. The Multitenancy module's post-auth middleware re-resolves the tenant from this claim before requests reach handlers, so EF Core's global query filter scopes to the impersonated user's tenant. The admin console adds the right `tenant: ` header to outbound calls automatically. From the operator's point of view, it just works. For the mechanics of why this needs post-auth middleware rather than Finbuckle's pre-auth claim strategy, see the [multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/). ## Audit query examples ### Every impersonation in the last 7 days ```http GET /api/v1/audits/security ?action=ImpersonationStarted,ImpersonationEnded,ImpersonationRevoked &fromUtc=2026-05-12T00:00:00Z &pageSize=50 ``` ### All impersonations by a specific actor ```http GET /api/v1/audits/security ?action=ImpersonationStarted &userId= &pageSize=100 ``` ### All impersonations targeting a specific user ```http GET /api/v1/audits ?eventType=Security &search=ImpersonatedUserId= ``` The `search` parameter does a substring match against the JSON payload, so it works for any field stored in `MetadataJson`. ## Operational policies A small set of policies pay for themselves quickly: - **Mandate `reason` on every start.** The kit's validator already enforces non-empty; reject one-word reasons in code review. - **Set a short default duration.** 15 minutes is plenty for most investigations. Forgotten 4-hour sessions are the most common operational issue. - **Alert on suspicious patterns.** One operator impersonating ten users in an hour is either incident response or a security event. Both are worth a Slack ping. - **Audit retention.** Set `Auditing:Retention:SecurityRetentionDays = 365` or longer. Impersonation events are the bread and butter of post-incident review. - **Train operators.** Impersonation is an investigation tool, not a debugging shortcut. "I just wanted to see what they see" is a red flag. ## Common questions ### Can a user tell they're being impersonated? Not from the impersonated session — the impersonation token looks identical to a regular access token from the inside. They can tell from the **audit log** if they have permission to read their own audit events. If you want active notification (a "your account was impersonated" email), add a handler on the `SecurityAction.ImpersonationStarted` audit event that calls `IMailService.SendAsync(to: targetUser.Email, ...)`. ~30 LoC in your fork. ### What if my admin loses their device mid-impersonation? The impersonation token expires automatically at `ExpiresAtUtc`. For belt-and-braces, configure a low default duration (15 min) and rely on token expiry. For immediate kill, another admin revokes via `DELETE /impersonation/grants/{id}` — the kit serves the revoked status on the next request validation. ### Can I impersonate myself? No business reason to. The kit doesn't block it explicitly (caller and target user ids can match), but it's a hard "why?" in any code review. ### Can impersonation be chained? Operator A impersonates user B, then user B (with operator powers) impersonates user C? The kit blocks this — `StartImpersonationCommand` rejects requests whose token already carries an impersonation `jti`. There's no impersonation-from-impersonation chain. ## Related - [Security reference: impersonation](/docs/security/impersonation/) — the underlying grant aggregate + lifecycle. - [Identity module](/docs/modules/identity/) — the full set of impersonation endpoints. - [Auditing module](/docs/modules/auditing/) — how the audit trail is captured. - [Architecture: multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — the cross-tenant resolution mechanics. - [Frontend: admin console](/docs/frontend/admin/) — the impersonation surface in the UI. --- # Staying current with upstream > How to take upstream fixes when you own the code — add the upstream remote, read the changelog, merge or re-apply a release, then rebuild, retest, and run pending migrations. Source: https://fullstackhero.net/docs/guides/upgrading/ fullstackhero is distributed as **source you own**. The template scaffolds the full codebase, modules and framework are consumed via `ProjectReference`, and nothing about your running app comes from a fullstackhero NuGet package — only the `fsh` CLI and the `dotnet new` template ship to NuGet ([the kit's `Directory.Build.props` turns `IsPackable` off for everything else](https://github.com/fullstackhero/dotnet-starter-kit/blob/main/src/Directory.Build.props)). That's a deliberate trade. You can change anything; in exchange, **taking upstream fixes is a git workflow, not a package bump**. There is no `dotnet update fullstackhero`. This page is the honest version of what that workflow looks like. `fsh update` updates the **tooling on your machine**: it runs `dotnet tool update -g FullStackHero.CLI` and reinstalls the latest `FullStackHero.NET.StarterKit` template. That affects the *next* project you scaffold — it never touches the code of a project you already have. Upgrading existing code is the git workflow below, and only that. ## How upstream ships - **`main` is the only long-lived branch** and stays releasable; stable releases are cut from `v*` tags (e.g. `v10.0.0`). See [Contributing](/docs/contributing/). - **The [changelog](/docs/changelog/) is the upgrade manual.** Entries are dated, explain *why* a change happened, and call out required actions — e.g. "grant the new `Webhooks.*` permissions or those users get `403`" or "wipe your Postgres data volume after the Aspire 13.4 bump". Read every entry between your base and your target before you merge anything. - One caution: tags below v10 (`2.0.4-rc` and older) belong to the previous generation of the kit. Don't try to merge across that rewrite boundary — v10 is the baseline. And if the release you're after isn't tagged yet, merge `upstream/main` at the commit matching its changelog entry instead of waiting on a tag. ## The upgrade workflow The mechanics depend on how you got the code, because that determines whether git can do the heavy lifting. ### If you cloned or forked the repo You share history with upstream, so this is an ordinary merge. ```bash # One-time setup git remote add upstream https://github.com/fullstackhero/dotnet-starter-kit.git # Each upgrade git fetch upstream --tags git log --oneline HEAD..upstream/main # what you're behind on # → read /docs/changelog/ for the same range — note any "action required" items git checkout -b chore/upstream-merge git merge v10.1.0 # a release tag, or upstream/main for latest ``` Conflicts land exactly where you'd expect: files you customized that upstream also changed. Resolve them knowing that *you* are the source of truth for your customizations and *upstream* is the source of truth for everything you never touched. You can also cherry-pick a single fix instead of taking a whole release — `git cherry-pick ` with the commit from the changelog or the [GitHub history](https://github.com/fullstackhero/dotnet-starter-kit/commits/main) — but track what you've picked, or the eventual full merge gets confusing. ### If you used GitHub's "Use this template" Your repo has the same files but **no shared history**, so the very first merge needs: ```bash git merge v10.1.0 --allow-unrelated-histories ``` Expect that first merge to be noisy (git can't tell "unchanged" from "both added"). After it lands, you have a common ancestor and every later merge behaves like the cloned-fork case. ### If you scaffolded with `fsh new` / `dotnet new fsh` This is the manual one, and we won't pretend otherwise. The template **renames everything** — `FSH.Starter` becomes `MyApp` across namespaces, project files, folder paths, and the solution — so upstream commits don't apply to your tree, and there's no shared history either. The realistic approach: 1. Read the changelog entries for the range you're taking. 2. View the upstream diff for each change: `git diff v10.0.0..v10.1.0 -- src/Modules/Tickets` against a local clone of the kit. 3. Re-apply by hand, translating `FSH.Starter.*` names to yours. Small, surgical fixes port in minutes; sweeping refactors are a judgment call on whether they're worth taking at all. For larger teams planning to track upstream closely, a plain fork (keeping the `FSH.Starter` names) is the smoother path — renaming is cosmetic, mergeability is structural. ## Verify after the merge The kit's test suite is the safety net — around 1,500 backend tests plus NetArchTest boundary checks, and 214 Playwright E2E tests across the two React apps. Use all of it: ```bash dotnet build src/FSH.Starter.slnx # warnings are errors dotnet test src/FSH.Starter.slnx # integration tests need Docker # New release may carry new EF migrations — review, then apply dotnet run --project src/Host/FSH.Starter.DbMigrator -- list-pending dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply # Frontends, if you took client changes cd clients/admin && npm install && npm run build && npx playwright test cd clients/dashboard && npm install && npm run build && npx playwright test ``` The `Architecture.Tests` project deserves a special mention: if your customizations accidentally violated module boundaries in a way that conflicts with an upstream change, it fails loudly instead of letting the merge compile into something subtly wrong. Finally, work through any **action-required items** from the changelog entries you just absorbed — permission grants, config keys, volume wipes. Those don't show up as compile errors. ## Make the next merge cheap Every conflict you'll ever resolve comes from editing a file upstream also edits. You control that surface: - **Put custom work in your own modules.** A new `Modules.{Name}` + `.Contracts` pair is yours alone — upstream will never touch it, so it merges for free. See [adding a module](/docs/architecture/). - **Don't modify `src/BuildingBlocks`.** It's the shared framework under every module and the highest-traffic code upstream maintains — the kit's own rules treat it as protected. Changes there put you in conflict with the most actively fixed code in the repo. - **Prefer extension points over editing shipped modules in place.** Swappable interfaces (storage providers, `IInvoicePdfRenderer`, event handlers on published events) let you change behavior without forking the file that implements it. When you must change a shipped module, keep the edit small and leave a comment marking it as yours — future-you resolves that conflict. - **Frontend dependencies are yours.** `clients/admin` and `clients/dashboard` each have their own `package-lock.json`; upstream pins what it ships, but auditing and updating npm packages on your schedule is your responsibility, like any app you own. ## Checklist 1. `git fetch upstream --tags` 2. Read [the changelog](/docs/changelog/) from your base to your target; list action-required items 3. Merge (or re-apply) on a branch — never directly on `main` 4. Resolve conflicts: yours wins on customizations, upstream wins on untouched code 5. `dotnet build` + `dotnet test` (Docker running), frontend builds + E2E if clients changed 6. `DbMigrator -- list-pending`, review, then `apply` 7. Apply changelog action items (permissions, config, infra) 8. Deploy ## Related - [Changelog](/docs/changelog/) — the dated record of every change, with action items. - [Install](/docs/getting-started/install/) — the acquisition paths this page's workflow depends on. - [CLI](/docs/cli/) — what `fsh update`, `info`, and `doctor` actually do. - [Contributing](/docs/contributing/) — branch and release model (`main` + `v*` tags). --- # Documentation > Browse fullstackhero by category — getting started, architecture, modules, building blocks, and more. Source: https://fullstackhero.net/docs/index/ A quick map of everything. New here? Start with **Getting Started**. Otherwise, pick the section that matches what you're trying to do. --- # Overview > Per-module deep dives — Identity, Multitenancy, Auditing, Webhooks, and the rest. Source: https://fullstackhero.net/docs/modules/ Ten ready-to-ship modules, each a bounded context with its own `DbContext`, contracts assembly, and feature folders. Pick one to dive into. --- # Auditing module > Entity-change capture via SaveChanges interceptor, HTTP middleware for request/response audit, security events, exception capture, JSON masking, and a retention purge job. Source: https://fullstackhero.net/docs/modules/auditing/ The Auditing module is fullstackhero's forensic record of everything that happened: every entity change, every HTTP request, every security action, every exception. It uses an EF Core `SaveChanges` interceptor for entity capture, a middleware for HTTP activity, a fluent builder for explicit security and exception events, JSON masking for sensitive fields, a channel-based publisher for non-blocking writes, a SQL primary sink with a file DLQ for failures, and a Hangfire retention job that purges old records on a configurable schedule. Around **3,300 lines of code across 48 files**, plus a contracts assembly. Seven read-only endpoints expose audits filtered by tenant, user, event type, severity, time, correlation, trace, source, and free-text search across the JSON payload. Plug them into your admin app and you have an investigation surface without writing any query code. ## What ships in v10 - **Entity-change capture** — `AuditingSaveChangesInterceptor` records every Insert / Update / Delete with before-and-after property snapshots for each entity. No code changes required in your handlers. - **HTTP activity capture** — `AuditHttpMiddleware` records every request / response with body, status code, duration, request id, trace id, user, tenant. Bodies are size-capped and content-type-filtered to keep audits small. - **Security events** — `Audit.ForSecurity(SecurityAction.LoginFailed).WithUser(...).WriteAsync()` for login attempts, permission denials, policy failures. - **Exception capture** — `Audit.ForException(ex).WithSeverity(...)` with `ExceptionSeverityClassifier` that maps common .NET exception types to sensible severities (e.g. `OperationCanceledException` → Information, `UnauthorizedAccessException` → Warning, everything else → Error). - **JSON masking** — `IAuditMaskingService` redacts sensitive fields (passwords, tokens, PII) before audits are written. Configurable patterns. - **Channel publisher → SQL sink with file DLQ** — audits go through a `System.Threading.Channels` pipeline so the request path isn't blocked. Writes go to PostgreSQL; failures fall back to a local file DLQ. - **Trigram GIN indexes** — PostgreSQL `pg_trgm` extension powers free-text search across `Source`, `UserName`, and other audit fields. - **Retention job** — opt-in Hangfire `AuditRetentionJob` purges old records on a daily cron, with per-event-type retention windows. - **`[NoAudit]` attribute** + `.NoAudit()` endpoint extension to disable auditing for sensitive endpoints (e.g. password reset doesn't need its body captured). - **7 read endpoints** under `/api/v1/audits/...` for querying. ## Architecture at a glance ``` src/Modules/Auditing/ ├── Modules.Auditing/ ~3,300 LoC, 48 files │ ├── AuditingModule.cs IModule entry — order 300 │ ├── Core/ │ │ ├── Audit.cs Fluent builder static factory │ │ ├── ChannelAuditPublisher.cs Non-blocking publisher │ │ ├── AuditBackgroundWorker.cs Consumes channel, writes to sinks │ │ ├── DefaultAuditClient.cs Implements IAuditClient │ │ └── SecurityAudit.cs Implements ISecurityAudit │ ├── Infrastructure/ │ │ ├── Http/AuditHttpMiddleware.cs Request/response capture │ │ └── Serialization/JsonMaskingService.cs Implements IAuditMaskingService │ ├── Persistence/ │ │ ├── AuditingSaveChangesInterceptor.cs EF Core hook │ │ ├── AuditRecord.cs The single row type (JSON payload) │ │ ├── AuditDbContext.cs PostgreSQL with GIN trigram │ │ ├── SqlAuditSink.cs, FileAuditDlqSink.cs Primary sink + file DLQ │ │ └── AuditRetentionJob.cs Hangfire daily purge │ └── Features/v1/ 7 read endpoints └── Modules.Auditing.Contracts/ Payload records (EntityChange, Security, Activity, Exception), options, enums, interfaces ``` ## The fluent builder The write surface is one factory class with four entry points: ```csharp // src/Modules/Auditing/Modules.Auditing/Core/Audit.cs await Audit.ForEntityChange("CatalogDbContext", "catalog", "Products", "Product", productId, EntityOperation.Update, changes) .WithUser(currentUser.GetUserId()) .WithTrace(activity?.TraceId.ToString()) .WithCorrelation(correlationId) .WriteAsync(ct); await Audit.ForSecurity(SecurityAction.LoginFailed) .WithSecurityContext(ipAddress, userAgent) .WithUser(attemptedUserId) .WithSeverity(AuditSeverity.Warning) .WriteAsync(ct); await Audit.ForActivity(ActivityKind.HttpRequest, "POST /api/v1/users/register") .WithActivityResult(statusCode, durationMs) .WriteAsync(ct); await Audit.ForException(ex, ExceptionArea.HttpRequest, route: ctx.Request.Path) .WriteAsync(ct); ``` The entity-change variant is the one **you almost never call directly** — the SaveChanges interceptor calls it for you on every Insert / Update / Delete. The other three are explicit one-line entries you scatter through code that needs them. ## What gets captured automatically Without any code changes, the module captures: 1. Every EF Core entity Insert / Update / Delete with property-level before / after values (`AuditingSaveChangesInterceptor`). 2. Every HTTP request and response with body, status, duration, headers (size-capped via `AuditHttpOptions.MaxRequestBytes` / `MaxResponseBytes`, content-type-filtered to JSON-ish by default). 3. Every unhandled exception that bubbles to the host's exception handler (severity classified automatically). What you wire manually: 4. Security actions you care about (login success / failure, permission denials, policy failures). 5. Custom activity entries when an HTTP-middleware capture isn't the right fit (long-running jobs, websocket actions, etc.). ## Public API — read queries | Type | Purpose | |---|---| | `GetAuditsQuery(paging, filters)` | Paginated list with filter by `FromUtc`, `ToUtc`, `TenantId`, `UserId`, `EventType`, `Severity`, `Tags`, `Source`, `CorrelationId`, `TraceId`, `Search` | | `GetAuditByIdQuery(auditId)` | Single record with full payload | | `GetAuditsByCorrelationQuery(correlationId)` | All records sharing a correlation ID | | `GetAuditsByTraceQuery(traceId)` | All records in a trace | | `GetSecurityAuditsQuery(...)` | Security events only | | `GetExceptionAuditsQuery(...)` | Exceptions only | | `GetAuditSummaryQuery(paging, grouping)` | Aggregations for dashboards | ## Endpoints | Verb | Route | Returns | |---|---|---| | GET | `/api/v1/audits` | Paginated list with filters | | GET | `/api/v1/audits/{id}` | Single audit with payload | | GET | `/api/v1/audits/by-correlation/{correlationId}` | Group by correlation | | GET | `/api/v1/audits/by-trace/{traceId}` | Group by trace | | GET | `/api/v1/audits/security` | Security-only filter | | GET | `/api/v1/audits/exceptions` | Exception-only filter | | GET | `/api/v1/audits/summary` | Aggregations | All seven require `Permissions.AuditTrails.View`. Results are scoped to the caller's tenant; asking for another tenant's audits (or the all-tenants view) additionally requires `Permissions.AuditTrails.ViewCrossTenant` — without it the handler throws a 403 rather than silently narrowing. One serialization quirk: enums like `Severity` and `EventType` round-trip as **strings** (the host registers a global `JsonStringEnumConverter`), but the two `[Flags]` enums — `AuditTag` and `BodyCapture` — stay **numeric** via a dedicated `NumericEnumConverter`, because flag combinations don't have a single string name. ## Configuration ```jsonc // appsettings.json { "Auditing": { "CaptureBodies": true, "MaxRequestBytes": 8192, "MaxResponseBytes": 16384, "AllowedContentTypes": ["application/json", "application/problem+json"], "ExcludePathStartsWith": ["/health", "/metrics", "/_framework", "/swagger", "/scalar", "/openapi"], "MinExceptionSeverity": "Error", "Retention": { "Enabled": false, // opt-in "ActivityRetentionDays": 30, "EntityChangeRetentionDays": 90, "SecurityRetentionDays": 365, "ExceptionRetentionDays": 180, "DeleteBatchSize": 5000, "Cron": "30 3 * * *" // daily at 03:30 UTC } } } ``` Audits are kept forever by default. Turn on `Auditing:Retention:Enabled` and schedule the cron when you have enough volume that bloat matters. Per-event-type retention windows mean security and exception data lives much longer than activity events. ## How to extend ### Mask additional fields `JsonMaskingService` is the default `IAuditMaskingService`. Replace or decorate it to add patterns: ```csharp public sealed class CustomAuditMaskingService(IAuditMaskingService inner) : IAuditMaskingService { private static readonly string[] ExtraSecrets = ["api_key", "client_secret", "private_key"]; public string Mask(string payload) { var masked = inner.Mask(payload); foreach (var key in ExtraSecrets) masked = MaskKey(masked, key); // your impl return masked; } } // register via Decorator pattern at module startup services.AddScoped(); ``` ### Add a new event payload type The single `AuditRecord` table stores arbitrary JSON payloads under `EventType`. To add a new payload class (e.g. `WebhookDeliveryEventPayload`): 1. Add a payload record in `Modules.Auditing.Contracts` (next to `SecurityEventPayload` and friends). 2. Extend the `AuditEventType` enum (in `AuditEnums.cs`) with a new value. 3. Add a builder entry point on the `Audit` static class. 4. Optionally add a dedicated read endpoint that filters by the new `EventType`. ### Opt an endpoint out of audit ```csharp endpoints.MapPost("/sensitive", handler) .RequirePermission(perm) .NoAudit(); ``` …or apply `[NoAudit]` to a handler method. ### Plug a different sink `IAuditSink` is the interface; `SqlAuditSink` is the default; `IAuditDlqSink` is the dead-letter sink. Add an OpenTelemetry sink, a Splunk sink, a Kafka sink — anything you need — by registering a different implementation. The channel publisher fans out so multiple sinks can coexist. ## Tests - Unit tests at `src/Tests/Auditing.Tests/` (5 test files) cover serialization, masking, retention logic. - Integration tests at `src/Tests/Integration.Tests/Tests/Auditing/` cover tenant isolation (no cross-tenant audit leaks) and the entity-change capture round-trip. ## Related - [Architecture: multitenancy deep-dive](/docs/architecture/) — how the audit query filters tenant scope safely. - [Cross-cutting concerns](/docs/cross-cutting-concerns/) — OpenTelemetry, Serilog, idempotency. - [Identity module](/docs/modules/identity/) — security events captured by Identity flow into Auditing automatically. --- # Billing module > Subscription plans, invoice lifecycle, and usage metering for multi-tenant SaaS — all wired through a monthly Hangfire job. Source: https://fullstackhero.net/docs/modules/billing/ The Billing module gives fullstackhero a working subscription-billing skeleton: a global catalogue of plans, per-tenant subscriptions driven by the tenant lifecycle (create + renew), a Draft → Issued → Paid invoice state machine, usage metering with overage rates, and a monthly Hangfire job that generates usage invoices for every active tenant. It's not a payment gateway (no Stripe SDK, no card webhooks), it's the **invoice + subscription primitives** every SaaS needs before it integrates a gateway. Use the Billing module if your product charges tenants on a recurring plan and you want the data model — plans, subscriptions, invoices, line items, usage snapshots — already on the table. Wire your payment gateway of choice into `MarkInvoicePaidCommand` when you're ready. ## What ships in v10 - A **global plan catalogue** (`BillingPlan`) — `Key`, name, currency, monthly base price, billing interval (`Monthly` or `Yearly` with optional flat `AnnualPrice`), per-resource overage rates keyed by `QuotaResource`. Plans are platform-wide (`IGlobalEntity`), so the same catalogue serves every tenant. - **Tenant-lifecycle wiring** — creating a tenant publishes `TenantSubscribedIntegrationEvent` and renewing one publishes `TenantRenewedIntegrationEvent` (both from the Multitenancy module); Billing's handlers react by starting/replacing the active subscription and issuing the term's subscription invoice. Invoice creation is idempotent (guarded by invoice number), and expiry past `ValidUpto` is grace-windowed via `Billing:GraceWindowDays`. - **Per-tenant subscriptions** with a small state machine — Active → Suspended → Active or Active → Cancelled. At most one Active subscription per tenant; assigning a plan cancels the prior active subscription. - An **invoice aggregate** with a clean state machine: Draft → Issued → (Paid \| Void). Line items are owned; subtotals recompute on add; period (year, month) is immutable. Every invoice carries a `Purpose` — `Subscription` (plan term, created on tenant create/renew) or `Usage` (metered overage, created by the monthly job) — so the two streams never collide on idempotency keys. - **Usage metering** — `UsageSnapshot` rows record what each tenant consumed in a window. The monthly job reads these against the subscribed plan's overage rates to add overage line items to the next invoice. - A **monthly Hangfire recurring job** (`billing-monthly-invoices`) registered at module startup. Runs on cron `5 0 1 * *` UTC — 00:05 UTC on the 1st of each month — and generates a Draft usage invoice per active tenant for the previous period. - **Email notifications** — issuing an invoice raises `InvoiceIssuedIntegrationEvent`, and a daily tenant-expiry scan raises nearing-expiry / entered-grace / expired events; the Notifications module emails the tenant admin. Expiry events are deduped so a tenant is notified once per state per validity window. - **On-demand PDF invoices** — `GET /invoices/{id}/pdf` renders the invoice to a PDF (QuestPDF, behind a swappable `IInvoicePdfRenderer`). The fetch is scoped to the caller's tenant, so the same endpoint serves operators and tenant self-service; another tenant's id returns `404`. - **16 endpoints** at `/api/v1/billing/...` covering plans, subscriptions, invoices, usage, and PDF download. Two coarse permissions: `Billing.View` and `Billing.Manage`. ## Architecture at a glance ``` src/Modules/Billing/ ├── Modules.Billing/ │ ├── BillingModule.cs IModule entry — order 500 │ ├── Domain/ │ │ ├── BillingPlan.cs Global plan (IGlobalEntity) │ │ ├── Subscription.cs Per-tenant subscription + state machine │ │ ├── Invoice.cs AggregateRoot — state machine │ │ ├── InvoiceLineItem.cs Owned by Invoice │ │ └── UsageSnapshot.cs Metering record │ ├── Data/ │ │ ├── BillingDbContext.cs Schema: billing — NOT per-tenant │ │ ├── Configurations/ EF type configs │ │ └── BillingDbInitializer.cs Seeds default plans │ ├── Features/v1/ │ │ ├── Plans/ 3 endpoints (+ GetPlanTerm internal query) │ │ ├── Subscriptions/ 3 endpoints │ │ ├── Invoices/ 8 endpoints (state machine + PDF) │ │ └── Usage/ 2 endpoints │ ├── IntegrationEventHandlers/ │ │ ├── TenantSubscribedIntegrationEventHandler.cs │ │ └── TenantRenewedIntegrationEventHandler.cs │ └── Services/ │ ├── BillingService.cs Invoice generation + transitions │ ├── UsageReporter.cs IUsageReporter implementation │ ├── InvoicePdfRenderer.cs IInvoicePdfRenderer (QuestPDF) │ └── MonthlyInvoiceJob.cs Hangfire recurring job └── Modules.Billing.Contracts/ Public commands/queries/events ``` The module loads at order `500` so it boots after Identity (`100`) and Multitenancy (`200`) but before consuming modules like Catalog (`600`). Unlike Catalog, Tickets, or Chat, the Billing module uses a single shared database — not a per-tenant database. `BillingDbContext` does not inherit tenant isolation. Subscriptions, invoices, and usage snapshots all carry an explicit `TenantId` column instead, and handlers filter on it manually. This is deliberate: billing is an admin/finance concern that needs to query *across* tenants (cross-tenant invoice reports, batch monthly job, etc.). Because the isolation is manual, every read handler is **root-gated**: only a caller from the root tenant may pass an arbitrary `tenantId` filter (or see all tenants); any other caller is forcibly pinned to their own tenant id, regardless of what they pass. If you add a new raw-`BillingDbContext` handler, copy that pattern — it's the module's standing security rule. ## The invoice state machine `Invoice` is the load-bearing aggregate. Its lifecycle is small but strict: ```csharp // src/Modules/Billing/Modules.Billing/Domain/Invoice.cs public sealed class Invoice : AggregateRoot { public InvoiceStatus Status { get; private set; } // Draft | Issued | Paid | Void public InvoicePurpose Purpose { get; private set; } // Subscription | Usage public static Invoice CreateDraft( string tenantId, string invoiceNumber, int periodYear, int periodMonth, string currency, InvoicePurpose purpose, DateTime? periodStartUtc, DateTime? periodEndUtc) { /* ... */ } public InvoiceLineItem AddLineItem( InvoiceLineItemKind kind, string description, decimal quantity, decimal unitPrice) { RequireStatus(InvoiceStatus.Draft); // only Draft accepts line items // ... recalculates SubtotalAmount } public void Issue(DateTime? dueAtUtc = null) { RequireStatus(InvoiceStatus.Draft); Status = InvoiceStatus.Issued; IssuedAtUtc = DateTime.UtcNow; DueAtUtc = dueAtUtc ?? IssuedAtUtc.Value.AddDays(14); } public void MarkPaid() { if (Status is InvoiceStatus.Paid) return; // idempotent if (Status is not InvoiceStatus.Issued) throw new InvalidOperationException($"Cannot mark invoice as paid from status {Status}."); Status = InvoiceStatus.Paid; PaidAtUtc = DateTime.UtcNow; } public void Void(string? reason = null) { if (Status is InvoiceStatus.Paid) throw new InvalidOperationException("Paid invoices cannot be voided."); if (Status is InvoiceStatus.Void) return; // idempotent Status = InvoiceStatus.Void; VoidedAtUtc = DateTime.UtcNow; } } ``` Each transition enforces the source state — you can't void a Paid invoice, can't `MarkPaid` a Draft, can't add line items to an Issued invoice. Re-paying a Paid invoice and re-voiding a Void one are deliberate no-ops (idempotent); genuinely invalid transitions throw `InvalidOperationException`. Enum values like `Status` serialize as **strings** in API responses (`"Issued"`, not `1`) — the host registers a global `JsonStringEnumConverter`. ## Public API The contracts assembly exposes every command, query, response, and enum that callers can use without referencing the runtime module. ### Plans | Type | Purpose | |---|---| | `CreatePlanCommand(key, name, currency, monthlyBasePrice, overageRates?, interval, annualPrice?)` | Admin: define a new plan | | `UpdatePlanCommand(planId, name, monthlyBasePrice, overageRates?, interval, annualPrice?)` | Admin: re-price an existing plan | | `GetPlansQuery(includeInactive?)` | List plans (active only by default) | | `GetPlanTermQuery(planKey)` | Resolve a plan's id + term length — used by Multitenancy's renew flow | ### Subscriptions | Type | Purpose | |---|---| | `AssignSubscriptionCommand(tenantId, planKey)` | Move a tenant onto a plan (cancels + replaces the active one) | | `GetSubscriptionQuery(tenantId?)` | Current subscription — root may pass any tenant id; others always get their own | ### Invoices | Type | Purpose | |---|---| | `GenerateInvoicesCommand(periodYear, periodMonth)` | Batch: create Draft usage invoices for every active tenant | | `IssueInvoiceCommand(invoiceId, dueAtUtc?)` | Draft → Issued | | `MarkInvoicePaidCommand(invoiceId)` | Issued → Paid | | `VoidInvoiceCommand(invoiceId, reason?)` | Issued/Draft → Void | | `GetInvoicesQuery(tenantId?, status?, periodYear?, periodMonth?, paging)` | Admin: paginated cross-tenant invoice list (root-gated) | | `GetMyInvoicesQuery(status?, periodYear?, periodMonth?, paging)` | Tenant-scoped invoice list | | `GetInvoiceByIdQuery(invoiceId)` | Single invoice with line items | ### Usage | Type | Purpose | |---|---| | `CaptureUsageSnapshotsCommand(tenantId, periodYear, periodMonth)` | Record one snapshot per `QuotaResource` for a tenant + period (idempotent) | | `GetUsageSnapshotsQuery(tenantId?, periodYear?, periodMonth?)` | Query history (root-gated for cross-tenant reads) | ## Endpoints All routes are mounted under `/api/v1/billing` and require the relevant `Billing.*` permission — except the two `/me` self-service reads, which are open to any authenticated tenant user so the dashboard can show the current plan and invoices. | Verb | Route | Permission | |---|---|---| | POST | `/plans` | `Billing.Manage` | | GET | `/plans` | `Billing.View` | | PUT | `/plans/{planId}` | `Billing.Manage` | | POST | `/subscriptions` | `Billing.Manage` | | GET | `/subscriptions?tenantId=` | `Billing.View` | | GET | `/subscriptions/me` | authenticated | | POST | `/invoices/generate` | `Billing.Manage` | | POST | `/invoices/{invoiceId}/issue` | `Billing.Manage` | | POST | `/invoices/{invoiceId}/pay` | `Billing.Manage` | | POST | `/invoices/{invoiceId}/void` | `Billing.Manage` | | GET | `/invoices` | `Billing.View` | | GET | `/invoices/me` | authenticated | | GET | `/invoices/{invoiceId}` | `Billing.View` | | GET | `/invoices/{invoiceId}/pdf` | `Billing.View` | | POST | `/usage/snapshots/capture` | `Billing.Manage` | | GET | `/usage` | `Billing.View` | ## Configuration **The `Billing` appsettings section** governs the tenant lifecycle side (it's bound by the Multitenancy and Identity modules as `TenantBillingOptions` / `TenantGraceOptions`, but it's billing behaviour, so it lives here): ```jsonc // appsettings.json { "Billing": { "DefaultPlanKey": "free", // plan assigned when CreateTenant has no explicit plan "GraceWindowDays": 7 // days past ValidUpto before requests are hard-blocked } } ``` **Plan catalogue** — seeded by `BillingDbInitializer` when the module first starts, or manage via the `/plans` endpoints at runtime. Plan `Key` should be canonical lowercase (e.g. `free`, `pro`, `enterprise`) and match the keys used by the Quota building block so quota and billing stay aligned. **Hangfire schedule** — the recurring job is registered at module startup via the `IRecurringJobManager`: ```csharp // MonthlyInvoiceJob registration (BillingModule.cs) jobManager.AddOrUpdate( "billing-monthly-invoices", Job.FromExpression(j => j.RunAsync(CancellationToken.None)), "5 0 1 * *", // 00:05 UTC on the 1st of every month new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); ``` To change the cadence, edit the cron expression. To run an ad-hoc generation (back-fill, testing), call `POST /api/v1/billing/invoices/generate` with the period. ## How to extend ### Wire a payment gateway `MarkInvoicePaidCommand` is the seam. Replace the handler (or add a decorator) to call your gateway: ```csharp public sealed class MarkInvoicePaidCommandHandler(BillingDbContext db, IStripeClient stripe) : ICommandHandler { public async ValueTask Handle(MarkInvoicePaidCommand cmd, CancellationToken ct) { var invoice = await db.Invoices.FirstAsync(i => i.Id == cmd.InvoiceId, ct).ConfigureAwait(false); // ... charge via gateway, capture payment-method-id on the invoice, then: invoice.MarkPaid(); await db.SaveChangesAsync(ct).ConfigureAwait(false); return invoice.Id; } } ``` For inbound webhooks (Stripe → your app), expose a webhook endpoint, validate the signature, look up the invoice by external ID, and call the same domain method. ### Add a new overage resource `BillingPlan.OverageRates` is keyed by the `QuotaResource` enum (`ApiCalls`, `StorageBytes`, `Users`, `ActiveFeatureFlags`), so overage resources line up one-to-one with what the Quota building block meters: 1. Add the new member to `QuotaResource` and give it a gauge/counter so Quota tracks it. 2. Update the plan(s) via `UpdatePlanCommand` to set the per-unit overage rate. 3. The monthly job (which captures snapshots per period via `IUsageReporter`) picks it up automatically. ### Emit domain events on plan changes Today the aggregates raise no domain events. If you need cross-module reactions (e.g. notify the tenant on plan upgrade), add an event to the relevant method: ```csharp public void Reactivate() { Status = SubscriptionStatus.Active; RaiseDomainEvent(new SubscriptionReactivatedDomainEvent(Id, TenantId)); } ``` Then handle it in another module via `IDomainEventHandler`. ## Tests Unit tests live at `src/Tests/Billing.Tests/` (domain state machines, services, validators). Integration tests at `src/Tests/Integration.Tests/Tests/Billing/` (8 files) cover the endpoint surface, tenant isolation/root-gating, the tenant billing lifecycle (create + renew → subscription + invoice), the monthly job, usage metering, and PDF rendering. ## Related - [Quota building block](/docs/building-blocks/quota/) — the plan keys here must line up with what Quota enforces. - [Multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/#the-documented-exception-billing) — why Billing opts out of the default tenant isolation. - [Modules overview](/docs/modules/) — the other nine modules that ship in v10. - [Cross-cutting concerns](/docs/cross-cutting-concerns/) — Hangfire, multitenancy, OpenTelemetry. --- # Catalog module > Production-grade product catalogue — brands, hierarchical categories, products with multi-image support, price + stock domain events, and soft delete. Source: https://fullstackhero.net/docs/modules/catalog/ The Catalog module is fullstackhero's most feature-complete reference implementation. It ships brands, a self-referential category tree, products with money-valued prices, a multi-image aggregate, full soft-delete plumbing, price + stock domain events, and paginated search — across **123 source files** with **28 endpoints**. Use it as the canonical example of a tenant-scoped CRUD module that's not a stub. If you're adding a new domain-heavy module to your fork, copy the Catalog folder structure verbatim. Three feature areas, owned-collection image management, soft delete via `ISoftDeletable`, `IFileAccessPolicy` integration with the Files module, and integration tests with Testcontainers — all the patterns the rest of the kit uses, in one place. ## What ships in v10 - **Brands** — flat list, name + slug + description + logo. Full CRUD plus soft delete, restore, search. - **Categories** — self-referential hierarchical tree, optional parent, lookup via `GetCategoryTreeQuery`. Full CRUD plus soft delete, restore, search. - **Products** — SKU, name, slug, description, brand, category, `Money` price (value object), stock, active flag. Full CRUD plus soft delete, restore, paginated search with filters. - **Multi-image products** — each product owns an ordered collection of `ProductImage` entries. The aggregate guarantees exactly one thumbnail, contiguous sort order, and automatic promotion when the current thumbnail is removed. - **Domain events** — `ProductCreatedDomainEvent`, `ProductPriceChangedDomainEvent`, `ProductStockAdjustedDomainEvent` for cross-module reactions. - **Files-module integration** — `ProductFileAccessPolicy` registers the `Product` owner type with the Files module: attach requires an authenticated caller (the durable gate is the product-update permission when the image lands on the product), read is public (product images ship with `Visibility=Public`), delete is uploader-only. Orphaned uploads that never attach are reaped by the Files orphan-purge job. - **16 fine-grained permissions** — View, Create, Update, Delete, Restore per resource (brand / category / product), plus `Catalog.Products.AdjustStock`. - **28 endpoints** under `/api/v1/catalog/...`. - **Recycle bin** — the soft-delete trash/restore endpoints back the tenant dashboard's Trash page (products, categories, and brands tabs, permission-gated). ## Architecture at a glance ``` src/Modules/Catalog/ ├── Modules.Catalog/ │ ├── CatalogModule.cs IModule entry — order 600 │ ├── Domain/ │ │ ├── Brand.cs ISoftDeletable │ │ ├── Category.cs Self-referential, ISoftDeletable │ │ ├── Product.cs AggregateRoot, ISoftDeletable, image owner │ │ ├── ProductImage.cs Owned by Product │ │ └── Money.cs Value object — EF persisted as owned type │ ├── Data/ │ │ ├── CatalogDbContext.cs Tenant-aware (extends BaseDbContext) │ │ ├── Configurations/ EF type configs │ │ └── CatalogDbInitializer.cs Seeds demo data when DemoSeeder runs │ ├── Features/v1/ │ │ ├── Brands/ 7 handlers + endpoints │ │ ├── Categories/ 8 handlers + endpoints │ │ └── Products/ 13 handlers + endpoints (including image ops) │ ├── Events/ Domain event handlers │ └── Authorization/ │ └── ProductFileAccessPolicy.cs Gates Files-module access └── Modules.Catalog.Contracts/ Public commands/queries/events/DTOs ``` The module loads at order `600`, after Identity, Multitenancy, Auditing, and Billing. Its `DbContext` is tenant-aware (each tenant has its own catalogue), so multitenancy is enforced through the EF Core global query filter automatically. ## The product + image aggregate The most interesting code in this module is `Product` enforcing image invariants. Images are an owned collection, not a separate aggregate. That's a deliberate choice: the rules ("exactly one thumbnail", "the product always has a cover while it has images") are aggregate-level invariants that belong inside the root. ```csharp // src/Modules/Catalog/Modules.Catalog/Domain/Product.cs public sealed class Product : AggregateRoot, ISoftDeletable { private readonly List _images = []; public IReadOnlyList Images => _images; public ProductImage AddImage(Guid? fileAssetId, string url) { ArgumentException.ThrowIfNullOrWhiteSpace(url); bool isFirst = _images.Count == 0; // first image is auto-thumbnail int order = isFirst ? 0 : _images.Max(i => i.SortOrder) + 1; var image = ProductImage.Create(Id, fileAssetId, url, isThumbnail: isFirst, sortOrder: order); _images.Add(image); UpdatedAtUtc = DateTime.UtcNow; return image; } public void RemoveImage(Guid imageId) { var image = _images.FirstOrDefault(i => i.Id == imageId) ?? throw new InvalidOperationException($"Image {imageId} not found on product {Id}."); bool wasThumbnail = image.IsThumbnail; _images.Remove(image); if (wasThumbnail && _images.Count > 0) { // lowest-sorted remaining image becomes the cover _images.OrderBy(i => i.SortOrder).First().MarkThumbnail(true); } UpdatedAtUtc = DateTime.UtcNow; } } ``` The four image endpoints (`AddProductImage`, `RemoveProductImage`, `SetProductThumbnail`, `ReorderProductImages`) each call exactly one aggregate method. The handler is one or two lines. ## Public API The contracts assembly has 28 commands and queries grouped into three areas. The full list is in `Modules.Catalog.Contracts/v1/`. Highlights: ```csharp // src/Modules/Catalog/Modules.Catalog.Contracts/v1/Products/CreateProductCommand.cs public sealed record CreateProductCommand( string Sku, string Name, string? Description, Guid BrandId, Guid CategoryId, decimal PriceAmount, string PriceCurrency, int Stock) : ICommand; // Price + stock get dedicated commands so callers can adjust either without // rewriting the product object — and so the handler can emit the right event. public sealed record ChangeProductPriceCommand( Guid ProductId, decimal Amount, string Currency) : ICommand; public sealed record AdjustProductStockCommand( Guid ProductId, int Delta) : ICommand; // returns the new stock level ``` ## Endpoints 28 endpoints under `/api/v1/catalog`. Highlights of the products subtree (the most interesting): | Verb | Route | What it does | |---|---|---| | POST | `/products` | Create a product | | GET | `/products/{productId}` | Fetch product with images | | PUT | `/products/{productId}` | Update name, description, brand, category, active | | PATCH | `/products/{productId}/price` | Change price, emit `ProductPriceChangedDomainEvent` | | PATCH | `/products/{productId}/stock` | Adjust stock by delta, emit `ProductStockAdjustedDomainEvent` | | DELETE | `/products/{productId}` | Soft delete | | POST | `/products/{productId}/restore` | Restore soft-deleted product | | GET | `/products/trash` | List soft-deleted products | | GET | `/products` | Paginated search — `search`, `brandId`, `categoryId`, `isActive`, `sortBy`, `sortDir` | | POST | `/products/{productId}/images` | Attach an image (with or without a FileAsset) | | DELETE | `/products/{productId}/images/{imageId}` | Detach image (auto-promotes next thumbnail) | | PUT | `/products/{productId}/images/{imageId}/thumbnail` | Promote image to thumbnail | | PUT | `/products/{productId}/images/order` | Reorder via `orderedImageIds[]` | Similar full CRUD + soft-delete + search shapes exist for `/brands` and `/categories`. Categories add a `/categories/tree` endpoint for the hierarchical view. `/trash` is registered before `/{id:guid}` so the literal segment wins the matcher. Adopt the same ordering if you add new soft-delete-aware subroutes. ## Domain events Three events ship with the module (each also carries an `EventId` and `OccurredOnUtc` from the `DomainEvent` base): - `ProductCreatedDomainEvent(Guid ProductId, string Sku, string Name)` — raised by `Product.Create`. - `ProductPriceChangedDomainEvent(Guid ProductId, decimal OldAmount, decimal NewAmount, string Currency)` — raised by `Product.ChangePrice`. - `ProductStockAdjustedDomainEvent(Guid ProductId, int OldStock, int NewStock, int Delta)` — raised by `Product.AdjustStock`. The `Modules.Catalog/Events/` folder contains in-module handlers. To react from another module, declare an integration event in your contracts and bridge it in the catalog event handler — keep cross-module dependencies one-way and contracts-only. ## Configuration The Catalog module is tenant-aware out of the box and reads no `IOptions`. Configure it through: - **`CatalogDbContext`** — schema `catalog`, tenant-aware via `BaseDbContext`. Each tenant gets its own products/brands/categories. - **Connection string** — uses the per-tenant connection resolved by `UseHeroMultiTenantDatabases()` in the composition root. - **Demo seed** — sample brands, categories, and products are populated when `FSH.Starter.DbMigrator seed-demo` runs (opt-in verb, dev-only by design; the seeding lives in the migrator's `DemoSeed` folder). ## How to extend ### Add a new resource (e.g. Suppliers) Mirror the brand structure: 1. New aggregate at `Modules.Catalog/Domain/Supplier.cs` inheriting `AggregateRoot` and `ISoftDeletable`. 2. EF configuration at `Data/Configurations/SupplierConfiguration.cs`. 3. Feature folders at `Features/v1/Suppliers/{Create,Update,Delete,Restore,Search,GetById,ListTrashed}/`. 4. Permission constants in `Contracts/Authorization/CatalogPermissions.cs`. 5. Register endpoints in `CatalogModule.MapEndpoints`. Architecture tests will fail the build if you skip the `Contracts` separation or use the wrong base. ### Wire price-change reactions Add an integration event in `Modules.Catalog.Contracts/IntegrationEvents/`: ```csharp public sealed record ProductRepricedIntegrationEvent( Guid ProductId, string Sku, decimal NewPriceAmount, string NewPriceCurrency) : IIntegrationEvent; ``` Publish from the in-module domain event handler: ```csharp public sealed class ProductPriceChangedDomainEventHandler(IEventBus bus) : INotificationHandler { public async ValueTask Handle(ProductPriceChangedDomainEvent evt, CancellationToken ct) => await bus.PublishAsync(new ProductRepricedIntegrationEvent(/* ... */), ct).ConfigureAwait(false); } ``` (In-module domain event handlers are Mediator `INotificationHandler` implementations — see `Modules.Catalog/Events/CatalogEventHandlers.cs` for the shipped ones, which currently just log.) Then any other module can subscribe via `IIntegrationEventHandler`. ### Replace search with full-text `SearchProductsQuery` today is a `Contains()` filter. To upgrade to PostgreSQL full-text search, add a generated `tsvector` column plus a GIN index in a raw-SQL migration and query it with `websearch_to_tsquery`. The Chat module already does this — copy the pattern from the `AddMessagesFullTextSearch` migration in `src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/` and the `FromSql` query in `Modules.Chat/Features/v1/Search/SearchMessagesQueryHandler.cs`. ## Tests Ten integration test files in `src/Tests/Integration.Tests/Tests/Catalog/` exercise every endpoint against Testcontainers Postgres + MinIO: - `BrandsEndpointTests.cs`, `CategoriesEndpointTests.cs`, `ProductsEndpointTests.cs` — CRUD + soft-delete/restore per resource - `ProductImagesTests.cs` and `ProductImageRemoveReorderTests.cs` — image add/remove/reorder/thumbnail flows - `UpdateProductBranchTests.cs` — update edge cases - `ProductFileAccessPolicyTests.cs` — the Files-module policy hook - `CatalogTenantIsolationTests.cs` — cross-tenant leakage guards - `PermissionRegistrationTests.cs` — verifies the permission constants are registered - `RolePermissionSyncerTests.cs` — verifies seeded role-permission mappings This is the most thoroughly tested module — use it as the reference for integration-test patterns. ## Related - [Files module](/docs/modules/files/) — product images attach to a `FileAsset`; the policy lives here. - [Architecture: vertical slice](/docs/architecture/vertical-slice/) — the feature-folder pattern this module uses. - [Modules overview](/docs/modules/) — the other nine modules. --- # Chat module > Slack-style messaging — named channels, 1:1 + group DMs, @mentions, thread replies, reactions, message pinning, full-text search, typing indicators, and SignalR realtime over Valkey. Source: https://fullstackhero.net/docs/modules/chat/ The Chat module turns fullstackhero into a working messaging app. Named channels (Slack-style), 1:1 direct messages (idempotent via a sorted key), group DMs, file attachments via the Files module, @mentions that publish integration events to Notifications, thread replies, emoji reactions, message pinning, full-text search, typing indicators with a Valkey-backed throttle, and SignalR realtime over a Valkey backplane (Valkey is a Redis-compatible, BSD-licensed Redis fork). Around **3,400 lines of code across the runtime project**, plus contracts. The most feature-dense module in v10. Anything else that needs realtime broadcast (live cursors, presence, collaborative editing) should follow Chat's pattern: a SignalR hub in `BuildingBlocks/Web/Realtime`, groups named `user:{id}` and `channel:{id}`, a 3-second distributed-cache throttle on noisy events, and integration events bridging to other modules. ## What ships in v10 - **Three channel kinds** — `Channel` (named, Slack-style), `DirectMessage` (1:1, idempotent via sorted `DirectKey`), `GroupMessage` (3+ users). - **Channel membership** with two roles — `Admin`, `Member`. - **Lossless archive + restore** — `DELETE /channels/{id}` archives (soft-deletes) a channel by flipping the flag instead of an EF `Remove()`, so `ChannelMember` rows survive; `POST /channels/{id}/restore` brings it back with memberships intact. - **Messages** with attachments, mentions, reactions; soft-deletable to a tombstone (`[deleted]`) so threads stay coherent. - **Thread replies** via `ParentMessageId`; `ReplyCount` tracked on the parent for UI badges. - **Pinning** — at most one pin per pin call, idempotent; `IsPinned` + `PinnedByUserId` + `PinnedAtUtc` on the message. - **Emoji reactions** with a unique `(MessageId, UserId, Emoji)` constraint preventing duplicates. - **@mentions** parsed server-side by `IMentionResolver`; each resolved user is published as a `MentionedInChannelIntegrationEvent` for the Notifications module to consume. - **Full-text search** across messages via PostgreSQL `tsvector` and a GIN index on a generated `BodyTsv` column. - **Typing indicator** with a 3-second per (channel, user) throttle in distributed cache. - **SignalR realtime** over a Valkey backplane (`AppHub` in `BuildingBlocks/Web/Realtime`); on connect every user joins `user:{id}`, `tenant:{tenantId}`, and every channel they belong to as `channel:{id}`. Channels created or joined *after* connect are joined on demand via the hub's `JoinChannel` method. - **Presence** — an in-memory `IPresenceTracker` broadcasts `PresenceChanged` to the tenant group on first connect / last disconnect; `GET /api/v1/realtime/presence?userIds=` serves the initial snapshot. - **22 endpoints** under `/api/v1/chat/...` plus the realtime hub at `/api/v1/realtime/hub`. - **File attachments** through the Files module's `ChatChannelFileAccessPolicy` (channel members can attach and read; only the uploader can delete). - **7 permissions** — `Chat.Channels` View / Create / ManageAll and `Chat.Messages` Send / EditOwn / DeleteOwn / DeleteAny. ## Architecture at a glance ``` src/Modules/Chat/ ├── Modules.Chat/ ~3,400 LoC │ ├── ChatModule.cs IModule — order 800 (after Notifications 750) │ ├── Domain/ │ │ ├── ChatChannel.cs AggregateRoot, ISoftDeletable (archive/restore) │ │ ├── ChannelMember.cs Membership row, Admin | Member │ │ ├── Message.cs AggregateRoot, tombstone delete │ │ ├── MessageAttachment.cs Owned by Message │ │ ├── MessageMention.cs Owned, position-tracked │ │ └── MessageReaction.cs Owned, unique (msg, user, emoji) │ ├── Data/ChatDbContext.cs Schema: chat (BodyTsv tsvector + GIN via raw migration) │ ├── Services/ │ │ ├── MentionParser.cs @username regex extraction │ │ ├── MentionResolver.cs username → user-id resolution │ │ ├── ChannelMembershipChecker.cs AppHub uses this on Typing()/JoinChannel() │ │ └── UserChannelLookup.cs Hub group join list on connect │ ├── Authorization/ │ │ └── ChatChannelFileAccessPolicy.cs IFileAccessPolicy (OwnerType=ChatChannel) │ └── Features/v1/ 22 endpoints in 4 areas └── Modules.Chat.Contracts/ Commands, queries, integration events ``` The module loads at order `800`, **after** Notifications (`750`) — the consumer of Chat's mention events sits at a lower order than the producer. (Handler discovery itself is order-independent: integration-event handlers are assembly-scanned into DI at startup; the ordering keeps the dependency direction obvious.) ## The channel + message aggregates ```csharp // src/Modules/Chat/Modules.Chat/Domain/ChatChannel.cs public sealed class ChatChannel : AggregateRoot, ISoftDeletable { public ChannelType Type { get; private set; } // Channel | DirectMessage | GroupMessage public string? DirectKey { get; private set; } // sorted "{userA}:{userB}" for DMs public bool IsPrivate { get; private set; } private readonly List _members = []; public IReadOnlyList Members => _members; public static ChatChannel CreateDirect(string userAId, string userBId) { var (lo, hi) = string.CompareOrdinal(userAId, userBId) < 0 ? (userAId, userBId) : (userBId, userAId); var c = new ChatChannel { Id = Guid.CreateVersion7(), Type = ChannelType.DirectMessage, IsPrivate = true, DirectKey = $"{lo}:{hi}", CreatedByUserId = userAId, CreatedAtUtc = DateTime.UtcNow, }; c._members.Add(ChannelMember.Create(c.Id, userAId, ChannelMemberRole.Member)); c._members.Add(ChannelMember.Create(c.Id, userBId, ChannelMemberRole.Member)); c.AddDomainEvent(DomainEvent.Create((id, ts) => new ChannelCreatedDomainEvent(c.Id, c.Type, null, userAId, id, ts))); return c; } } ``` `DirectKey` is the idempotency trick: a unique index on `DirectKey` makes "find or create a DM between A and B" a single round trip with no race window. (User ids are strings throughout Chat — they come straight off the JWT.) Archiving is an explicit state flip, not an EF `Remove()` — removing the aggregate would cascade-delete the `ChannelMember` rows, so a later restore would come back memberless. `Archive(deletedByUserId)` just sets the soft-delete flag and `Restore()` clears it, which is what makes channel restore lossless. `Message` is similar but does not implement `ISoftDeletable` — deleted messages are tombstones so reply chains keep working: ```csharp public void SoftDelete(string deletingUserId, bool isModerator) { if (DeletedAtUtc.HasValue) return; if (!isModerator && !string.Equals(AuthorUserId, deletingUserId, StringComparison.Ordinal)) { throw new InvalidOperationException("Only the author or a moderator can delete."); } DeletedAtUtc = DateTime.UtcNow; Body = null; // UI renders "[deleted]" AddDomainEvent(DomainEvent.Create((id, ts) => new MessageDeletedDomainEvent(ChannelId, Id, AuthorUserId, id, ts))); } ``` ## Realtime The kit's `AppHub` is in `BuildingBlocks/Web/Realtime/AppHub.cs`, mapped at `/api/v1/realtime/hub`. On connect it joins the user to: - `user:{userId}` — for personal events (notification pushes, channel-added) - `tenant:{tenantId}` — scopes presence broadcasts to one tenant - `channel:{channelId}` for every channel the user belongs to at connect time The hub exposes two client-callable methods. `Typing(channelId)` broadcasts a typing indicator, throttled to once per 3 s per (channel, user) via the distributed cache: ```csharp public async Task Typing(Guid channelId) { var userId = GetUserId(); if (string.IsNullOrEmpty(userId)) return; if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false)) return; var key = $"typing:{channelId}:{userId}"; if (!string.IsNullOrEmpty(await _cache.GetStringAsync(key, ...).ConfigureAwait(false))) return; await _cache.SetStringAsync(key, "1", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TypingThrottle }, ...); await Clients.OthersInGroup($"channel:{channelId}") .SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted); } ``` `JoinChannel(channelId)` joins the `channel:{id}` group on demand, gated by the same membership check. Connect-time pre-join only covers channels that existed (and that the user was a member of) when the socket opened — a DM created or a membership granted *after* connect would never receive broadcasts until a page reload. Clients call `JoinChannel` when they open a conversation and on reconnect; re-joining is a no-op. The throttle prevents the chatty per-keystroke pattern from saturating channels. Other realtime events (`ChatMessageCreated`, `ChatMessageEdited`, `ChatMessageDeleted`, `ChatMessagePinned`/`ChatMessageUnpinned`, `ChatReactionChanged`, `ChatChannelAdded`/`ChatChannelRemoved`, `ChatChannelMemberAdded`/`ChatChannelMemberRemoved`, `ChatChannelRead`, `NotificationCreated`, `PresenceChanged`) are broadcast from handlers post-SaveChanges. ## Public API Full surface in `Modules.Chat.Contracts/`. Highlights: ### Channels `CreateChannelCommand(name, description, isPrivate)`, `UpdateChannelCommand`, `ArchiveChannelCommand`, `RestoreChannelCommand`, `AddChannelMembersCommand`, `RemoveChannelMemberCommand`, `MarkChannelReadCommand(channelId, messageId)` (read watermark up to a message), and `FindOrCreateDmCommand(userIds)` → channel id. One other user id finds-or-creates the 1:1 DM via `DirectKey`; two or more creates a fresh group DM. Queries: `ListMyChannelsQuery`, `DiscoverChannelsQuery`, `GetChannelByIdQuery`. ### Messages `SendMessageCommand(channelId, body?, parentMessageId?, attachments)` — `body` is optional because attachment-only messages are allowed. `EditMessageCommand`, `DeleteMessageCommand`, `PinMessageCommand`, `UnpinMessageCommand`, plus `ListChannelMessagesQuery`, `ListMessageRepliesQuery`, `GetPinnedMessagesQuery`, `SearchMessagesQuery`. ### Reactions `AddReactionCommand(messageId, emoji)`, `RemoveReactionCommand(messageId, emoji)`. ## Endpoints 22 endpoints under `/api/v1/chat`. Major routes: | Verb | Route | What it does | |---|---|---| | GET | `/channels` | List my channels | | GET | `/channels/discover` | Public channels I'm not in | | GET | `/channels/{id}` | Channel details + members | | POST | `/channels` | Create named channel | | PUT | `/channels/{id}` | Rename / change privacy | | DELETE | `/channels/{id}` | Archive (soft delete, members preserved) | | POST | `/channels/{id}/restore` | Restore archived channel (lossless) | | POST | `/dms` | Find or create DM / group DM (idempotent for 1:1) | | POST | `/channels/{id}/members` | Add members | | DELETE | `/channels/{id}/members/{userId}` | Remove a member | | POST | `/channels/{id}/read` | Update read watermark | | GET | `/channels/{id}/messages` | Cursor-paged message list | | GET | `/channels/{id}/pinned` | Pinned messages | | POST | `/channels/{id}/messages` | Send message (+ mentions + attachments) | | GET | `/messages/{id}/replies` | Thread replies | | PUT | `/messages/{id}` | Edit message | | DELETE | `/messages/{id}` | Soft delete to tombstone | | POST | `/messages/{id}/pin` | Pin | | DELETE | `/messages/{id}/pin` | Unpin | | POST | `/messages/{id}/reactions` | Add reaction | | DELETE | `/messages/{id}/reactions/{emoji}` | Remove reaction | | GET | `/search?q=&channelId=` | Full-text search | ## @mention → notification bridge The flow lives in `SendMessageCommandHandler` and is split across two small services: 1. `MentionParser.Parse(body)` — a `[GeneratedRegex(@"(?` directly. Configuration that matters lives in adjacent blocks: - **SignalR / Valkey backplane** — set `CachingOptions:Redis` to point at your Valkey. The `AddHeroRealtime` registration calls `AddStackExchangeRedis(...)` automatically (channel prefix `fsh-signalr`); leave it empty and the hub runs in single-host mode, which is what tests and bare-bones dev use. - **Files attachments** — `ChatChannelFileAccessPolicy` is registered automatically during `ConfigureServices`. Uploads go through `/api/v1/files/upload-url` with `OwnerType=ChatChannel` and `OwnerId={channelId}`. ## How to extend ### Add a new realtime event Define a payload, broadcast post-SaveChanges from the handler: ```csharp await _hub.Clients.Group($"channel:{channelId}").SendAsync( "ChatReactionChanged", new { channelId, messageId, userId, emoji, kind = "added" }, ct).ConfigureAwait(false); ``` ### Restrict mentions to channel members only `MentionResolver` resolves any username globally. To restrict to channel membership, look up `IUserChannelLookup` for each candidate and drop non-members. ### Add an emoji deny-list Add the rule to `AddReactionCommandValidator` (every command handler already has a FluentValidation validator) and reject unwanted emoji codes before the handler touches the aggregate. ## Tests - Domain tests at `src/Tests/Chat.Tests/` — channel invariants (DM membership, DirectKey), message state (edit by author, soft-delete tombstone), pin idempotency, reaction toggling. - Integration tests under `src/Tests/Integration.Tests/Tests/Chat/` exercise REST + SignalR via Testcontainers (long-polling forced since TestServer has no WebSockets): channels, messages, threads + reactions, pinning, search, typing indicators, `JoinChannel`, presence, realtime event broadcasts, mention→notification, channel file access, and tenant isolation. ## Related - [Notifications module](/docs/modules/notifications/) — receives `MentionedInChannelIntegrationEvent`. - [Files module](/docs/modules/files/) — chat attachments through `ChatChannelFileAccessPolicy`. - [Cross-cutting concerns](/docs/cross-cutting-concerns/) — SignalR, Valkey backplane, Hangfire. --- # Files module > Presigned-URL file lifecycle with pluggable per-OwnerType access policies, soft delete + retention purge, optional scanner hook, and S3 / MinIO storage. Source: https://fullstackhero.net/docs/modules/files/ The Files module is the kit's shared object-storage layer. Any other module that needs file uploads (Catalog product images, Chat attachments, user avatars) goes through it. Files are owned by an `OwnerType` (e.g. `Product`, `ChatChannel`, `User`) and an `OwnerId`; per-OwnerType `IFileAccessPolicy` implementations decide who can attach, read, delete, and change visibility. Storage is MinIO / S3 via presigned URLs — the API never proxies bytes. (The Storage building block also ships a local-disk provider with emulated presign tokens for environments without object storage.) If your module owns files, ship a `FileAccessPolicy : IFileAccessPolicy` next to the rest of its code. Register it during your module's `ConfigureServices`. The Files module never knows about your domain — your domain knows what's allowed. ## What ships in v10 - **Presigned upload flow** — `POST /files/upload-url` mints a presigned PUT URL with a 15-minute TTL (configurable). Client uploads directly to MinIO / S3. `POST /files/{id}/finalize` flips the file from `PendingUpload` to `Available`. - **Pluggable access policies** — `IFileAccessPolicy` interface registered per `OwnerType`. Catalog ships `ProductFileAccessPolicy`, Chat ships `ChatChannelFileAccessPolicy`, the Files module itself registers `DefaultUploaderOnlyPolicy` for the built-in `MyFiles` and `User` owner types. - **Per-category validation** — extension whitelist + size cap per category (`Image` 10 MB, `Document` 25 MB, `Archive` 50 MB), configured in `appsettings`. - **Visibility** — files are `Public` or `Private`; `PATCH /files/{id}/visibility` flips it after upload (policy-gated, only on `Available` files). `GET /files/shared` lists the tenant's public free-standing files (`MyFiles` / `User` owner types) for a "Shared in tenant" surface. - **File scanner hook** — `IFileScanner.ScanAsync(storageKey)` with `NoOpFileScanner` as the default (always `Clean`). Implement it to plug in ClamAV / GuardDuty / VirusTotal; an `Infected` scan result transitions the file to `Quarantined` instead of `Available`. - **Soft delete + retention** — deleted files go to trash; `PurgeDeletedFilesJob` (Hangfire, daily 03:30 UTC) hard-deletes after 30 days. The tenant dashboard's Trash page restores from here (permission-gated tab). - **Orphan cleanup** — `PurgeOrphanedFilesJob` (hourly) deletes `PendingUpload` rows whose upload deadline passed without a finalize call. - **Storage quota metering** — finalize records the uploaded bytes against the tenant's `StorageBytes` quota. - **`FileFinalizedIntegrationEvent`** — published when a file is finalized (`Available` or `Quarantined`), carrying owner type/id, content type, size, and final status. No built-in consumer ships today — Catalog and Chat attach files via explicit commands carrying the `fileAssetId` + URL — but it's the hook for search indexing, notifications, and the like. - **5 permissions** — `Files.Upload`, `DeleteOwn`, `DeleteAny`, `ViewTrash`, `Restore`. ## Architecture at a glance ``` src/Modules/Files/ ├── Modules.Files/ ~1,600 LoC │ ├── FilesModule.cs IModule entry — order 350 │ ├── FilesOptions.cs Bound from the "Files" appsettings section │ ├── Domain/FileAsset.cs AggregateRoot, state machine, ISoftDeletable │ ├── Data/FilesDbContext.cs Schema: files │ ├── Authorization/ │ │ └── DefaultUploaderOnlyPolicy.cs For MyFiles + User │ ├── Services/ │ │ ├── FileAccessPolicyRegistry.cs OwnerType → IFileAccessPolicy lookup │ │ ├── StorageKeyBuilder.cs Tenant + category path generation │ │ └── NoOpFileScanner.cs Default IFileScanner │ ├── Jobs/ │ │ ├── PurgeOrphanedFilesJob.cs Hangfire hourly │ │ └── PurgeDeletedFilesJob.cs Hangfire 03:30 daily │ └── Features/v1/ 10 features └── Modules.Files.Contracts/ ~250 LoC, incl. IFileAccessPolicy ``` ## The lifecycle `FileAsset` is the aggregate. Three states, transitions one-way: ``` finalize PendingUpload ────────────────────► Available │ │ scan = Infected └──────────────────────────► Quarantined ``` Upload-deadline expiry transitions `PendingUpload` to hard-deleted via the orphan-purge job. `Available` and `Quarantined` files are soft-deletable; the daily purge hard-deletes after the retention window. ```csharp // src/Modules/Files/Modules.Files/Domain/FileAsset.cs public sealed class FileAsset : AggregateRoot, ISoftDeletable { public FileAssetStatus Status { get; private set; } // PendingUpload | Available | Quarantined public DateTimeOffset? UploadDeadline { get; private set; } public static FileAsset CreatePending( Guid id, string ownerType, Guid? ownerId, string originalFileName, string sanitizedFileName, string contentType, long declaredSizeBytes, string storageKey, Visibility visibility, string createdByUserId, DateTimeOffset uploadDeadline) { /* ... */ } public void MarkAvailable(long actualSize, ScanStatus scanResult) { // throws 409 unless Status == PendingUpload SizeBytes = actualSize; ScanStatus = scanResult; Status = scanResult == ScanStatus.Infected ? FileAssetStatus.Quarantined : FileAssetStatus.Available; UploadDeadline = null; AddDomainEvent(DomainEvent.Create((id, ts) => new FileFinalizedDomainEvent(Id, OwnerType, OwnerId, Status, id, ts))); } public void ChangeVisibility(Visibility next) { /* 409 unless Available */ } } ``` Finalize HEADs the uploaded object, rejects uploads larger than the declared size (plus a 1 % slack), runs the scanner, and records the bytes against the tenant's storage quota. ## Access policies `IFileAccessPolicy` is the seam every consuming module implements: ```csharp // src/Modules/Files/Modules.Files.Contracts/IFileAccessPolicy.cs public interface IFileAccessPolicy { string OwnerType { get; } Task CanAttachAsync(Guid? ownerId, string currentUserId, CancellationToken cancellationToken); Task CanReadAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken); Task CanDeleteAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken); // defaults to the CanDelete rule; override to forbid visibility flips entirely Task CanChangeVisibilityAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) => CanDeleteAsync(context, currentUserId, cancellationToken); } ``` Policies receive a `FileAccessContext` record (file id, owner type/id, uploader, visibility) and a primitive `currentUserId` rather than a `ClaimsPrincipal`, so the contract stays free of ASP.NET Core types. Tenant scoping is enforced by `BaseDbContext`, not delegated to policies. Catalog's `ProductFileAccessPolicy` allows any authenticated user to attach (the durable gate is the product-update permission when the image is attached to the product), open read (product images are public), and uploader-only delete. Chat's `ChatChannelFileAccessPolicy` requires channel membership to attach and read; delete is uploader-only. `FileAccessPolicyRegistry` looks up the right policy by `OwnerType`. Missing policy → request rejected. This is a deliberate fail-closed default. ## Public API | Type | Purpose | |---|---| | `RequestUploadUrlCommand(ownerType, ownerId?, fileName, contentType, sizeBytes, visibility, category)` | Mints a presigned PUT URL and a pending `FileAsset` | | `FinalizeUploadCommand(fileAssetId)` | HEADs the object, scans, flips to Available/Quarantined | | `ChangeFileVisibilityCommand(fileAssetId, visibility)` | Flip Public ↔ Private (policy-gated) | | `DeleteFileCommand(fileId)` | Soft delete | | `RestoreFileCommand(fileId)` | Undelete | | `GetFileMetadataQuery(fileId)` | Single file's metadata | | `GetFileDownloadUrlQuery(fileId)` | Mints a presigned GET URL | | `ListMyFilesQuery(paging)` | Paginated caller-scoped list | | `ListSharedFilesQuery(paging)` | Public tenant-wide files (MyFiles / User owner types) | | `ListTrashedFilesQuery(paging)` | Paginated trash | The `PresignedUploadResponse` returned by `RequestUploadUrl` carries `FileAssetId`, `UploadUrl`, the `RequiredHeaders` the client must send with the PUT, and `ExpiresAt`. The download response is a presigned GET URL. ## Endpoints | Verb | Route | Permission | What it does | |---|---|---|---| | POST | `/api/v1/files/upload-url` | `Files.Upload` | Mint presigned PUT URL | | POST | `/api/v1/files/{id}/finalize` | — (policy) | Flip to Available, trigger scan | | GET | `/api/v1/files/{id}` | — (policy) | File metadata (not content) | | GET | `/api/v1/files/{id}/url` | — (policy) | Mint presigned GET URL | | PATCH | `/api/v1/files/{id}/visibility` | `Files.Upload` | Flip Public ↔ Private | | DELETE | `/api/v1/files/{id}` | `Files.DeleteOwn` | Soft delete | | POST | `/api/v1/files/{id}/restore` | `Files.Restore` | Restore from trash | | GET | `/api/v1/files/mine` | `Files.Upload` | Paginated caller-scoped list | | GET | `/api/v1/files/shared` | `Files.Upload` | Public tenant-wide files | | GET | `/api/v1/files/trash` | `Files.ViewTrash` | Paginated trash | Endpoints marked "— (policy)" require authentication only; the per-OwnerType `IFileAccessPolicy` makes the call. ## Configuration ```jsonc // appsettings.json { "Files": { "UploadUrlTtlMinutes": 15, "DownloadUrlTtlMinutes": 60, "OrphanRetentionMinutes": 60, "SoftDeleteRetentionDays": 30, "Categories": { "Image": { "AllowedExtensions": [".jpg",".jpeg",".png",".webp",".gif",".ico"], "MaxBytes": 10485760 }, "Document": { "AllowedExtensions": [".pdf",".docx",".xlsx",".pptx",".txt",".csv"], "MaxBytes": 26214400 }, "Archive": { "AllowedExtensions": [".zip"], "MaxBytes": 52428800 } } } } ``` The section binds to `FilesOptions` (`Modules.Files/FilesOptions.cs`); category names are matched case-insensitively. The S3 / MinIO target is configured in the [Storage building block](/docs/building-blocks/storage/) — the Files module is the policy + lifecycle layer on top. ## How to extend ### Add a policy for a new owner type ```csharp public sealed class ContractDocumentAccessPolicy(ContractsDbContext db) : IFileAccessPolicy { public string OwnerType => "Contract"; public async Task CanAttachAsync(Guid? ownerId, string currentUserId, CancellationToken cancellationToken) { if (ownerId is null) return false; var contract = await db.Contracts.FindAsync([ownerId.Value], cancellationToken).ConfigureAwait(false); return contract is not null && string.Equals(contract.OwnerUserId, currentUserId, StringComparison.Ordinal); } public Task CanReadAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) { /* ... */ } public Task CanDeleteAsync(FileAccessContext context, string currentUserId, CancellationToken cancellationToken) { /* ... */ } } // register in your module's ConfigureServices services.AddScoped(); ``` ### Plug a virus scanner Implement `IFileScanner.ScanAsync(storageKey, ct)` (return `ScanStatus.Clean` or `Infected`) and replace the `NoOpFileScanner` registration in DI. `FinalizeUploadCommandHandler` calls the scanner during finalize; an `Infected` result transitions the file to `Quarantined` instead of `Available`. ### Listen for file finalization in another module ```csharp public sealed class FileFinalizedNotifyHandler(/* ... */) : IIntegrationEventHandler { public async Task HandleAsync(FileFinalizedIntegrationEvent evt, CancellationToken ct = default) { if (evt.OwnerType != "Product") return; // react to the upload completing — index it, notify someone, etc. } } ``` No module ships a consumer today — Catalog and Chat attach files by passing the `fileAssetId` + public URL through their own commands (`AddProductImageCommand`, `SendMessageCommand` attachments). The event is the seam for anything that should *react* to an upload completing. ## Tests - Domain + service tests at `src/Tests/Files.Tests/`: - `FileAssetTests` — state transitions - `DefaultUploaderOnlyPolicyTests` — policy enforcement - `FileAccessPolicyRegistryTests` — registry lookup - `StorageKeyBuilderTests` — tenant + category path generation - Integration tests at `src/Tests/Integration.Tests/Tests/Files/` (ten files) cover the presigned round-trip (`RequestAndFinalizeUploadTests`, `StorageFlowTests`), upload validation, finalize edge cases, visibility + sharing, soft delete + restore, the purge jobs, and tenant isolation against Testcontainers MinIO. ## Related - [Storage building block](/docs/building-blocks/storage/) — the S3 / MinIO abstraction this module sits on top of. - [Catalog module](/docs/modules/catalog/) — product images use the policy hook. - [Chat module](/docs/modules/chat/) — channel attachments use the policy hook. --- # Identity module > JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement. Source: https://fullstackhero.net/docs/modules/identity/ The Identity module is fullstackhero's load-bearing auth layer. It wraps ASP.NET Identity with JWT bearer + refresh, a flat-but-granular permission system enforced via `.RequirePermission()` on endpoints, user groups, operator impersonation with a full audit trail, two-factor TOTP, per-device sessions, password history, and an outbox-backed integration event publisher for things like user registration and password changes. Around **10,400 lines of code across 205 files**, it's the most substantial module in the kit. If you're customising auth — adding SSO, replacing JWT with cookie auth, swapping the user store — start here. The kit puts auth on the foundation of ASP.NET Identity, so anything you'd do in a plain ASP.NET app you can do in fullstackhero. Nothing about the rest of the kit assumes a bespoke identity stack. ## What ships in v10 - **JWT bearer + refresh tokens** with a rotating refresh on each `POST /token/refresh`. - **ASP.NET Identity** on top of `FshUser : IdentityUser` and `FshRole : IdentityRole` — custom password policy, 5-attempt lockout for 15 minutes, custom claims, passkey table. - **Permission-based authorization** via a flat registry (`PermissionConstants`) and the `.RequirePermission(...)` fluent helper on endpoints. - **User groups** — organise users; groups own roles (`GroupRoles`) and propagate permissions to members. - **Operator impersonation** — a SuperAdmin can issue a time-bound impersonation grant against another user (cross-tenant supported via `IGlobalEntity`), with revocation list and an audit trail. - **Two-factor TOTP** — enrol with a QR code, verify, disable per user. Optional, not enforced. - **Sessions** — per-device records with IP, user-agent, OS, browser, and revocation; cleanup runs as a hosted service. - **Password policy** — history (default 5), expiry (default 90 days) with warning window (14 days), enforced via `IPasswordHistoryService` + `IPasswordExpiryService`. - **Permission catalog** — `GET /permissions/catalog` returns every permission registered with the host, filtered to the caller's tenant context (root vs admin set). The admin app's role editor is built on it. - **51 endpoints** under `/api/v1/identity/...` covering tokens, users, roles, the permission catalog, groups, sessions, impersonation, and 2FA. Rate-limited `auth` policy applied to login, refresh, confirm-email, resend-confirmation-email, forgot-password, reset-password, and self-register. - **Outbox-backed integration events** — `UserRegisteredIntegrationEvent` and `TokenGeneratedIntegrationEvent` go out via the Eventing module's outbox dispatcher. ## Architecture at a glance ``` src/Modules/Identity/ ├── Modules.Identity/ ~10,400 LoC, 205 files │ ├── IdentityModule.cs IModule entry — order 100 │ ├── Domain/ │ │ ├── FshUser.cs IdentityUser + IHasDomainEvents │ │ ├── FshRole.cs IdentityRole + Description │ │ ├── Group.cs, GroupRole.cs, UserGroup.cs Groups + group-owned roles │ │ ├── ImpersonationGrant.cs IGlobalEntity (cross-tenant) │ │ ├── UserSession.cs IHasDomainEvents │ │ └── PasswordHistory.cs │ ├── Data/IdentityDbContext.cs MultiTenantIdentityDbContext │ ├── Features/v1/ 51 endpoints in 8 areas │ │ ├── Tokens/ Generate, Refresh │ │ ├── Users/ Register + 19 more features │ │ ├── Roles/ 6 features │ │ ├── Permissions/ Permission catalog │ │ ├── Groups/ 8 features │ │ ├── Sessions/ 7 features │ │ ├── Impersonation/ 4 features │ │ └── TwoFactor/ 3 features │ └── Services/ Split by responsibility │ ├── ITokenService IssueAsync, IssueAccessOnlyAsync │ ├── ICurrentUserService ICurrentUser implementation │ ├── IUserRegistrationService User creation │ ├── IUserPasswordService Change/reset/expiry checks │ ├── IUserPermissionService Role/group permission aggregation │ ├── ISessionService Session CRUD + cleanup │ └── IImpersonationGrantService Grant + revoke └── Modules.Identity.Contracts/ Public commands/queries/services ``` The module loads at order `100` — the first module — so every later module can rely on `ICurrentUser`, the JWT pipeline, and the permission registry being already in place. ## The token pipeline Login goes through `POST /api/v1/identity/token/issue`. The handler validates email/password through `UserManager`, checks 2FA if enrolled, mints an access token + refresh token via `ITokenService`, opens a `UserSession`, and publishes a `TokenGeneratedIntegrationEvent` to the outbox. ```csharp // src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/ public record GenerateTokenCommand( string Email, string Password, string? TwoFactorCode = null) : ICommand; public sealed record TokenResponse( string AccessToken, string RefreshToken, DateTime RefreshTokenExpiresAt, DateTime AccessTokenExpiresAt); public record RefreshTokenCommand(string? Token, string RefreshToken) : ICommand; ``` The access token carries the standard claims plus `tenant` (resolved via Finbuckle on the request that issued it) and the user's **roles** (direct + group-derived). Permissions themselves stay server-side — they're resolved from the role set on each authorization check, so a permission change takes effect without re-issuing tokens. Enforcement on an endpoint is just: ```csharp endpoints.MapPost("/users", handler) .RequirePermission(IdentityPermissions.Users.Create); ``` `PathAwareAuthorizationHandler` lets you mix anonymous endpoints (the login itself, `forgot-password`, `confirm-email`) with permission-gated ones without separate policies per endpoint. ## Public API highlights 51 endpoints means a lot of contracts. Major ones: ### Tokens | Type | Purpose | |---|---| | `GenerateTokenCommand(email, password, twoFactorCode?)` | Login | | `RefreshTokenCommand(token?, refreshToken)` | Rotate the refresh token, mint a new access token | ### Users | Type | Purpose | |---|---| | `RegisterUserCommand` | Operator-driven user creation; also reused by anonymous `/self-register` (rate-limited) | | `ConfirmEmailCommand(userId, code, tenant)` | Email confirmation step (link-friendly `GET`) | | `ForgotPasswordCommand(email)` → token | Issue reset token | | `ResetPasswordCommand(email, password, token)` | Apply reset; respects history | | `ChangePasswordCommand(password, newPassword, confirmNewPassword)` | Self-service change | | `ToggleUserStatusCommand(userId, activateUser)` | Activate / deactivate user | | `AssignUserRolesCommand(userId, userRoles[])` | Replace user's role set | | `SetProfileImageCommand(imageUrl?)` | Profile image (null clears it) | | `GetCurrentUserProfileQuery(userId)` | Current user profile | ### Roles | Type | Purpose | |---|---| | `UpsertRoleCommand(id, name, description?)` | Create or rename | | `UpdatePermissionsCommand` | Set the role's permission set | | `GetRoleWithPermissionsQuery(id)` | Fetch a role with its current permissions | ### Sessions `GetMySessionsQuery`, `RevokeSessionCommand`, `RevokeAllSessionsCommand(exceptSessionId?)`, plus admin variants (`GetTenantSessionsQuery`, `GetUserSessionsQuery`, `AdminRevokeSessionCommand`, `AdminRevokeAllSessionsCommand`) for ops. ### Impersonation `StartImpersonationCommand(targetUserId, targetTenantId, reason?, durationMinutes?)` returns an impersonation access token (duration capped server-side at 60 minutes). `EndImpersonationCommand` ends the active grant and returns a fresh operator token. `RevokeImpersonationGrantCommand(grantId, reason?)` is the kill switch for admins. ### Two-Factor `EnrollTwoFactorCommand` returns a QR code + secret; `VerifyEnrollTwoFactorCommand(code)` activates; `DisableTwoFactorCommand(currentPassword)` turns it off. ## Endpoints All 51 endpoints are under `/api/v1/identity/`. The rate-limited `auth` policy covers `POST /token/issue`, `POST /token/refresh`, `GET /confirm-email`, `POST /users/{id}/resend-confirmation-email`, `POST /forgot-password`, `POST /reset-password`, and `POST /self-register`. Full table: | Verb | Route | What it does | |---|---|---| | POST | `/token/issue` | Login | | POST | `/token/refresh` | Rotate refresh token | | GET | `/profile` | Current user profile | | PUT | `/profile` | Update own profile | | PUT | `/profile/image` | Set / clear profile image | | GET | `/permissions` | Current user permissions | | GET | `/permissions/catalog` | All registered permissions (catalog) | | POST | `/register` | Operator-driven user creation | | POST | `/self-register` | Anonymous registration | | POST | `/forgot-password` | Issue password-reset token | | POST | `/reset-password` | Reset using token | | GET | `/confirm-email` | Confirm email (`?userId=&code=&tenant=`) | | POST | `/change-password` | Change own password | | GET | `/users` | List users | | GET | `/users/search` | Search users | | GET | `/users/{id}` | Get user by id | | DELETE | `/users/{id}` | Delete user | | PATCH | `/users/{id}` | Activate / deactivate | | POST | `/users/{id}/confirm-email` | Admin: confirm a user's email | | POST | `/users/{id}/resend-confirmation-email` | Resend confirmation email | | POST | `/users/{id}/roles` | Assign roles | | GET | `/users/{id}/roles` | List user roles | | GET | `/users/{userId}/groups` | List user groups | | GET | `/roles` | List roles | | GET | `/roles/{id}` | Get role | | POST | `/roles` | Upsert role (create or update) | | DELETE | `/roles/{id}` | Delete role | | GET | `/{id}/permissions` | Get role with permissions | | PUT | `/{id}/permissions` | Update role permissions | | GET | `/groups` | List groups | | GET | `/groups/{id}` | Get group | | POST | `/groups` | Create group | | PUT | `/groups/{id}` | Update group | | DELETE | `/groups/{id}` | Delete group | | GET | `/groups/{groupId}/members` | List members | | POST | `/groups/{groupId}/members` | Add members | | DELETE | `/groups/{groupId}/members/{userId}` | Remove member | | GET | `/sessions/me` | My sessions | | DELETE | `/sessions/{sessionId}` | Revoke a session | | POST | `/sessions/revoke-all` | Revoke all my sessions (optionally keep one) | | GET | `/sessions` | Tenant sessions (admin, paged) | | GET | `/users/{userId}/sessions` | User sessions (admin) | | DELETE | `/users/{userId}/sessions/{sessionId}` | Admin revoke one session | | POST | `/users/{userId}/sessions/revoke-all` | Admin revoke all user sessions | | POST | `/impersonation/start` | Start impersonation | | POST | `/impersonation/end` | End impersonation | | GET | `/impersonation/grants` | List grants | | POST | `/impersonation/grants/{id}/revoke` | Revoke grant | | POST | `/2fa/enroll` | Enroll 2FA | | POST | `/2fa/verify` | Verify enrolment | | POST | `/2fa/disable` | Disable 2FA (requires current password) | Yes, the role-permission routes really are `/{id}/permissions` directly under `/api/v1/identity` — there's no `/roles/` segment in them. ## Configuration ```jsonc // appsettings.json { "PasswordPolicy": { "PasswordHistoryCount": 5, // prevent reuse of the last N passwords "PasswordExpiryDays": 90, // force change cadence "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true }, "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", "AccessTokenMinutes": 45, "RefreshTokenDays": 7, "SigningKey": "set via secrets manager or env var" } } ``` Lockout is configured by `IdentityOptions` and defaults to 5 failed attempts → 15-minute lock. Outbox events are dispatched by the framework's `OutboxDispatcherHostedService` (on by default) — the module deliberately registers no dispatcher of its own, since a second one would race the same rows. ## How to extend ### Add a new permission 1. Pick a stable resource + action pair (e.g. `Users`, `Export`). 2. Add a constant to `IdentityPermissions` in the Contracts assembly. 3. Register it through `PermissionConstants.Register([...])` during module startup (Identity already does this; mirror the pattern). 4. Apply it on the endpoint: `.RequirePermission(IdentityPermissions.Users.Export)`. 5. Seed roles with the permission via `UpdatePermissionsCommand` (`PUT /{id}/permissions`) or the role-permission seeder. ### Add a custom claim to the JWT Subclass `TokenService` (or wrap with a decorator) and add the claim during `IssueAsync`. The existing user metadata service is the canonical place to source the claim value from. ### Swap email confirmation for an OTP / magic link The `ConfirmEmailCommand` flow is a token exchange. Replace the token-generation step (currently `UserManager.GenerateEmailConfirmationTokenAsync`) and the redeem step (`ConfirmEmailAsync`) to issue and accept an OTP. The endpoint surface stays the same. ## Tests - Unit tests at `src/Tests/Identity.Tests/` (30 test files) cover handlers, password policy, JWT options. - Integration tests at `src/Tests/Integration.Tests/Tests/` — the `Impersonation/`, `Sessions/`, `Users/`, `Roles/`, `Groups/`, `Authentication/`, and `Authorization/` folders all exercise this module end to end. ## Related - [Security](/docs/security/) — overall security posture beyond Identity. - [Multitenancy module](/docs/modules/multitenancy/) — tenant resolution + per-tenant connection strings. - [Architecture: dependency injection](/docs/architecture/) — how the module loader wires `ICurrentUser` and permissions. --- # Multitenancy module > Finbuckle-driven tenant resolution (claim, header, query), distributed-cache store, per-tenant connection strings, theme customisation, and an async provisioning state machine. Source: https://fullstackhero.net/docs/modules/multitenancy/ The Multitenancy module makes multi-tenant the default behaviour of the entire kit. It wires Finbuckle.MultiTenant 10 with a three-tier resolution strategy (claim → header → query), caches resolved tenants in a distributed store, supports per-tenant connection strings, ships a resumable provisioning state machine, drives the tenant subscription lifecycle (create with a plan, renew, expire with a grace window), and exposes a per-tenant theme customisation surface. Around **3,200 lines of code across 58 files**, plus a contracts assembly the rest of the kit references. Once this module is loaded (order 200), every other module's `DbContext` is tenant-aware via the EF Core global query filter. You opt **out** with `IGlobalEntity`, you don't opt in. That's the discipline. ## What ships in v10 - **Three-tier tenant resolution** — claim, header (`tenant` is the primary), query string (`?tenant=`). Finbuckle resolves first match wins. - **DistributedCacheStore** in front of the EF Core store — a resolved tenant is cached for 60 minutes to keep request hot-paths off the DB. - **EFCoreStore** as the source of truth — tenants live in `TenantDbContext` with `AppTenantInfo` as the record. - **Per-tenant connection strings** — each `AppTenantInfo` can carry its own DB connection, set on creation. - **`IGlobalEntity` opt-out** — entities that need to live across tenants (plans, impersonation grants, outbox messages) mark this interface and skip the tenant filter. - **Root-operator header override** — SuperAdmin can scope a single request to another tenant via the `tenant` header for admin operations like cross-tenant user search; runs as post-auth middleware (the Finbuckle strategies see anonymous principals). - **Provisioning state machine** — multi-step tenant creation (Database → Migrations → Seeding → CacheWarm). Steps are persisted; failures are resumable via `RetryTenantProvisioning`. - **Tenant lifecycle with plans** — `CreateTenantCommand` takes an optional `PlanKey` (falls back to `Billing:DefaultPlanKey`) and publishes `TenantSubscribedIntegrationEvent`; `RenewTenant` extends `ValidUpto` by the plan term and publishes `TenantRenewedIntegrationEvent` — the Billing module reacts to both with subscriptions + invoices. `AdjustTenantValidity` is the operator override that *may* backdate (the internal `SetValidity` used by renewal is forward-only and throws on backdating). - **Active/expiry enforcement as post-auth guards** — Finbuckle happily resolves inactive or expired tenants; two middleware guards (registered in `IModule.ConfigureMiddleware`) reject requests for deactivated tenants and enforce `ValidUpto` with a configurable grace window (`Billing:GraceWindowDays`, default 7). Inside the window, responses carry an `X-Subscription-Grace` header with days left. - **Daily expiry scan** — the `tenant-expiry-scan` Hangfire job (02:00 UTC) publishes nearing-expiry / entered-grace / expired events, deduped per tenant per validity window via `TenantExpiryNotice`. - **Tenant themes** — full light + dark palettes, brand assets, typography, layout knobs. The frontend reads these to render per-tenant branding. ## Architecture at a glance ``` src/Modules/Multitenancy/ ├── Modules.Multitenancy/ ~3,200 LoC, 58 files │ ├── MultitenancyModule.cs IModule entry — order 200 │ ├── TenantBillingOptions.cs "Billing" config section binding │ ├── Domain/ │ │ ├── TenantTheme.cs Per-tenant theme row │ │ └── TenantExpiryNotice.cs Dedupe record for expiry notifications │ ├── Data/ │ │ └── TenantDbContext.cs EFCoreStoreDbContext │ ├── Features/v1/ 13 features │ │ ├── CreateTenant/ Create + kick off provisioning │ │ ├── GetTenants/, GetTenantStatus/, GetMyTenantStatus/ │ │ ├── ChangeTenantActivation/, RenewTenant/, AdjustTenantValidity/ │ │ ├── GetTenantTheme/, UpdateTenantTheme/, ResetTenantTheme/ │ │ └── TenantProvisioning/ (status + retry), GetTenantMigrations/ │ ├── Provisioning/ │ │ ├── TenantProvisioning.cs State machine + ordered step rows │ │ ├── TenantProvisioningService.cs Orchestrates steps, idempotent retries │ │ └── TenantProvisioningJob.cs Hangfire job │ └── Services/ TenantService, TenantThemeService, │ TenantExpiryScanJob, TenantInitialPasswordBuffer └── Modules.Multitenancy.Contracts/ Commands, queries, DTOs, integration events ``` ## How resolution actually works The Finbuckle strategy chain runs on every request, before authentication. The kit configures it like this: ```csharp // MultitenancyModule.cs (simplified) builder.Services.AddMultiTenant() .WithClaimStrategy(ClaimConstants.Tenant) // 1. claim — no-op pre-auth .WithHeaderStrategy(MultitenancyConstants.Identifier) // 2. "tenant" header — primary .WithDelegateStrategy(ResolveTenantFromQuery) // 3. ?tenant= query string fallback .WithDistributedCacheStore(TimeSpan.FromMinutes(60)) .WithStore>(ServiceLifetime.Scoped); ``` Finbuckle's strategy chain runs before `UseAuthentication`, so the claim strategy sees an anonymous principal on every request — it's effectively a no-op in normal flow. The **header strategy is the real primary resolver**. The claim strategy is there for the cross-tenant impersonation case where a downstream pipeline can re-resolve with claims. The **root-operator header override** is what lets a SuperAdmin (whose JWT carries `tenant=root`) scope a single request to another tenant. It runs as post-auth middleware via `IModule.ConfigureMiddleware`, checks that the caller is in the root tenant, reads the `tenant` header on the request, looks up the target, and replaces the multi-tenant context on the `HttpContext`. Two details worth knowing about the cache layer: a tenant resolved from the EF store is written through to the distributed cache on resolution (`OnTenantResolveCompleted`), and every tenant write path (`activation`, `renew`, `adjust-validity`, theme) refreshes the cached entry immediately — otherwise flips would lag up to the 60-minute TTL. ## Public API The Contracts assembly exposes the surface the host and admin app talk to. ### Tenant lifecycle | Type | Purpose | |---|---| | `CreateTenantCommand(id, name, connectionString?, adminEmail, adminPassword, issuer?, planKey?)` | Creates a tenant, fires async provisioning, publishes `TenantSubscribedIntegrationEvent` | | `GetTenantsQuery(paging, search)` | Admin list view | | `GetTenantStatusQuery(tenantId)` | Fetch a single tenant's status | | `ChangeTenantActivationCommand(tenantId, isActive)` | Toggle active | | `RenewTenantCommand(tenantId, planKey?)` | Extend `ValidUpto` by the plan term (forward-only), publishes `TenantRenewedIntegrationEvent` | | `AdjustTenantValidityCommand(tenantId, validUpto)` | Operator override — may move the date backward (immediate expiry, corrections) | `GetMyTenantStatus` has no command type — the endpoint reads the resolved tenant context directly and returns the caller's own status. ### Provisioning | Type | Purpose | |---|---| | `GetTenantProvisioningStatusQuery(tenantId)` | Returns step-level state | | `RetryTenantProvisioningCommand(tenantId)` | Resume from the first failed step | | `GetTenantMigrationsQuery()` | Lists applied/pending EF migrations across tenant databases | ### Themes Theme types are scoped to the *caller's* tenant context (no tenant id parameter — operators target a tenant via the root header override): | Type | Purpose | |---|---| | `GetTenantThemeQuery()` | Read | | `UpdateTenantThemeCommand(theme)` | Update palette / brand / typography | | `ResetTenantThemeCommand()` | Restore defaults | ## Endpoints All under `/api/v1/tenants`. Permissions are from `MultitenancyPermissions.Tenants` (`Permissions.Tenants.*`). | Verb | Route | What it does | Permission | |---|---|---|---| | POST | `/` | Create tenant + provision | `Tenants.Create` | | GET | `/` | List tenants | `Tenants.View` | | GET | `/{id}/status` | Get status | `Tenants.View` | | GET | `/me/status` | Calling tenant's own status (plan, validity, grace) | authenticated | | POST | `/{id}/activation` | Activate / deactivate | `Tenants.Update` | | POST | `/{id}/renew` | Renew subscription by plan term | `Tenants.UpgradeSubscription` | | POST | `/{id}/adjust-validity` | Operator validity override | `Tenants.UpgradeSubscription` | | GET | `/{tenantId}/provisioning` | Step-by-step provisioning state | `Tenants.View` | | POST | `/{tenantId}/provisioning/retry` | Resume failed provisioning | `Tenants.Update` | | GET | `/migrations` | Migration status across tenant DBs | `Tenants.View` | | GET | `/theme` | Get current tenant's theme | `Tenants.ViewTheme` | | PUT | `/theme` | Update theme | `Tenants.UpdateTheme` | | POST | `/theme/reset` | Reset theme | `Tenants.UpdateTheme` | ## Provisioning state machine `CreateTenantCommand` returns immediately. The handler writes the `AppTenantInfo` row, writes a `TenantProvisioning` record with four ordered steps — `Database`, `Migrations`, `Seeding`, `CacheWarm` — buffers the admin password in `ITenantInitialPasswordBuffer` (singleton), and queues a Hangfire `TenantProvisioningJob`. The job picks up the work, walks each step idempotently, and updates the persisted state. If any step throws, the step is marked `Failed`, the overall state is `Failed`, and the buffered admin password is left in place. `RetryTenantProvisioning` resumes from the first failed step. ```csharp // src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs public sealed class TenantProvisioning { public string TenantId { get; private set; } public string CorrelationId { get; private set; } public TenantProvisioningStatus Status { get; private set; } // Pending | Running | Completed | Failed public string? CurrentStep { get; private set; } public string? Error { get; private set; } public string? JobId { get; private set; } public ICollection Steps { get; private set; } } ``` ## Configuration Resolution itself is wired in code through Finbuckle setup; what you configure in appsettings is the store plumbing and the subscription-lifecycle knobs: ```jsonc // appsettings.json { "DatabaseOptions": { "Provider": "POSTGRESQL", "ConnectionString": "Host=...;Database=fsh;Username=...;Password=..." }, "CachingOptions": { "Redis": "localhost:6379" // distributed cache for DistributedCacheStore }, "Billing": { // bound to TenantBillingOptions "DefaultPlanKey": "free", // plan used when CreateTenant has no PlanKey "GraceWindowDays": 7, // days past ValidUpto before hard block "ExpiryNotificationLeadDays": 7 // how early the daily scan starts warning } } ``` The root tenant is seeded by the database initialiser. Per-tenant connection strings are set during `CreateTenantCommand`; if omitted, tenants share the main connection. ## How to extend ### Add a new resolution strategy To support subdomain-based tenant resolution (`acme.fullstackhero.net`), register an additional Finbuckle strategy: ```csharp .WithHostStrategy("__tenant__") ``` …and configure your DNS / load balancer to inject the tenant slug into the hostname pattern. ### Add a tenant-aware service to your module Implement `IHasTenant` on the entity (already done if it inherits `BaseEntity`), keep the DbContext extending `BaseDbContext`, and the global query filter handles isolation automatically. To opt out for a system-wide table, implement `IGlobalEntity` on the entity. ### Cross-tenant queries from an admin context Use `IgnoreQueryFilters()` deliberately, then re-filter explicitly to whatever scope you want: ```csharp // Pattern from the kit's audit query — verified safe pattern var audits = await db.AuditRecords .IgnoreQueryFilters() .Where(a => allowedTenantIds.Contains(a.TenantId.Value)) .ToListAsync(ct).ConfigureAwait(false); ``` The [multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) covers when to use `IgnoreQueryFilters()` (drops every filter) versus `IgnoreQueryFilters([QueryFilters.SoftDelete])` (drops only the named soft-delete filter) for soft-delete-aware cross-tenant queries. ## Tests - Unit tests at `src/Tests/Multitenancy.Tests/` (9 test files). - Integration tests at `src/Tests/Integration.Tests/Tests/Multitenancy/` (17 files) cover tenant isolation (no leaks), provisioning status + failure/retry, header override, activation, renewal, validity adjustment, expiry enforcement + the expiry scan job, themes, and seed-data flows. ## Related - [Identity module](/docs/modules/identity/) — the per-tenant user store this module's tenancy underlies. - [Architecture: multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — `IGlobalEntity`, named filters, isolation tests. - [Persistence building block](/docs/building-blocks/persistence/) — `BaseDbContext`, the auto-applied global filter. --- # Notifications module > Per-user inbox driven by integration events from other modules — denormalized rows, real-time SignalR push, idempotent mark-read. Source: https://fullstackhero.net/docs/modules/notifications/ The Notifications module is the inbox layer of fullstackhero. It owns one denormalized row type (`Notification`), exposes four read/mark endpoints, and is driven entirely by integration events published by other modules. Today it consumes Chat mentions (inbox row + realtime push) and the Billing lifecycle events (tenant-admin emails for invoices and expiry); tickets, webhooks, and anything else can opt in next. New notifications are pushed to the recipient in real time via the same SignalR hub Chat uses, so the bell-icon badge updates without a refresh. Around **800 lines of code across the runtime project** — intentionally thin. This module has no business logic of its own. It's a destination — other modules publish integration events that describe what happened ("user X was mentioned in channel Y"), and the handler materialises a row plus a SignalR push. Adding new notification types means adding a new `IIntegrationEventHandler` here, not redesigning the inbox. ## What ships in v10 - **One row type** — `Notification(Id, UserId, Type, Title, Body?, Link?, Source, MetadataJson, CreatedAtUtc, ReadAtUtc?)`. Denormalized so the inbox renders without back-references into the source module. - **Real-time push** — the integration-event handler broadcasts a `NotificationCreated` SignalR event to the recipient's `user:{id}` group immediately after writing the row. - **Idempotent mark-read** — `Notification.ReadAtUtc ??= DateTime.UtcNow` means calling mark-read twice is a no-op. - **Bulk mark-all-read** via `ExecuteUpdateAsync` — single SQL `UPDATE`, no row materialisation, scoped to the caller; returns the number of rows touched. - **Four endpoints** — `GET /` (inbox, with an `unreadOnly` filter), `GET /unread-count` (badge), `POST /{id}/read` (single), `POST /read-all` (bulk). - **Billing lifecycle emails** — handlers for the Billing module's `InvoiceIssued`, `TenantNearingExpiry`, `TenantEnteredGrace`, and `TenantExpired` integration events email the tenant admin via the Mailing building block's `IMailService`. - **Free-form `Type` + `MetadataJson`** — source modules pick the strings (`chat.mention`, `ticket.assigned`, `billing.invoice.issued`) and the UI uses them to render the right icon and link. - **2 permissions** — `Notifications.Inbox.View` and `Notifications.Inbox.MarkRead`, both granted to Basic users by default. ## Architecture at a glance ``` src/Modules/Notifications/ ├── Modules.Notifications/ ~800 LoC │ ├── NotificationsModule.cs IModule — order 750 (BEFORE Chat 800) │ ├── Domain/Notification.cs AggregateRoot, denormalized inbox row │ ├── Data/NotificationsDbContext.cs Schema: notifications │ ├── IntegrationEventHandlers/ │ │ ├── MentionedInChannelIntegrationEventHandler.cs Chat mention → inbox + push │ │ ├── InvoiceIssuedEmailHandler.cs Billing → tenant-admin email │ │ ├── TenantNearingExpiryEmailHandler.cs Billing → tenant-admin email │ │ ├── TenantEnteredGraceEmailHandler.cs Billing → tenant-admin email │ │ ├── TenantExpiredEmailHandler.cs Billing → tenant-admin email │ │ └── BillingEmailSender.cs / BillingEmailBodies.cs Shared email plumbing │ └── Features/v1/ 4 endpoints └── Modules.Notifications.Contracts/ Commands, queries, DTOs, permissions ``` Notifications loads at order `750`, before Chat at `800` — consumers ahead of producers. Handler *discovery* doesn't actually depend on this (integration-event handlers are assembly-scanned into DI at startup and resolved at publish time), but keeping the consumer at a lower order keeps the dependency direction obvious and DI/middleware registration deterministic. ## The handler bridge The whole module is essentially a fan-out from integration events to inbox rows + SignalR pushes. The Chat-mention handler is the canonical example: ```csharp // src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/MentionedInChannelIntegrationEventHandler.cs public sealed class MentionedInChannelIntegrationEventHandler( NotificationsDbContext db, IHubContext hub, IMultiTenantContextAccessor tenantAccessor, ILogger logger) : IIntegrationEventHandler { public async Task HandleAsync(MentionedInChannelIntegrationEvent @event, CancellationToken ct = default) { // Fail loud on a tenant mismatch rather than write to the wrong tenant — the DbContext // captures its tenant at construction. var ambientTenantId = tenantAccessor.MultiTenantContext.TenantInfo?.Id; if (!string.Equals(ambientTenantId, @event.TenantId, StringComparison.Ordinal)) throw new InvalidOperationException("Tenant context mismatch ..."); var notification = Notification.Create( userId: @event.MentionedUserId, type: "chat.mention", title: string.IsNullOrEmpty(@event.ChannelName) ? "You were mentioned in a conversation" : $"You were mentioned in #{@event.ChannelName}", body: @event.BodyPreview, link: $"/chat/{@event.ChannelId}?messageId={@event.MessageId}", source: @event.Source, metadata: new { channelId = @event.ChannelId, messageId = @event.MessageId, /* … */ }); db.Notifications.Add(notification); await db.SaveChangesAsync(ct).ConfigureAwait(false); await hub.Clients.Group($"user:{@event.MentionedUserId}") .SendAsync("NotificationCreated", new { id = notification.Id, /* dto fields */ }, ct) .ConfigureAwait(false); } } ``` The handler runs **synchronously** in the originating SendMessage request scope (in-memory bus, synchronous dispatch). If the write or push fails, the SendMessage call fails. That's deliberate — fail-fast over silent loss of notifications. If you need it to be optional later, wrap with a try-catch and log. The tenant-mismatch guard exists because the DbContext captures its tenant at construction — a publisher that didn't establish the Finbuckle tenant context would otherwise leak rows cross-tenant silently. ## The row type ```csharp // src/Modules/Notifications/Modules.Notifications/Domain/Notification.cs public sealed class Notification : AggregateRoot { public string UserId { get; private set; } = default!; public string Type { get; private set; } = default!; // e.g. "chat.mention" public string Title { get; private set; } = default!; public string? Body { get; private set; } public string? Link { get; private set; } public string Source { get; private set; } = default!; // originating module, e.g. "Chat" public string MetadataJson { get; private set; } = "{}"; // opaque, source-defined shape public DateTime? ReadAtUtc { get; private set; } // null = unread public DateTime CreatedAtUtc { get; private set; } public void MarkRead() => ReadAtUtc ??= DateTime.UtcNow; // idempotent } ``` A composite index on `(UserId, ReadAtUtc, CreatedAtUtc)` covers the inbox list (always `WHERE UserId = ? ORDER BY CreatedAtUtc DESC`, optionally unread-filtered) and the unread-count query. `MetadataJson` is stored as `jsonb`. ## Public API | Type | Purpose | |---|---| | `ListNotificationsQuery(unreadOnly, page, pageSize)` | Caller-scoped paginated list, newest first | | `GetUnreadCountQuery()` | Integer for the bell badge | | `MarkNotificationReadCommand(notificationId)` | Idempotent; 404 on cross-user access | | `MarkAllNotificationsReadCommand()` | Bulk caller-scoped via `ExecuteUpdateAsync`; returns rows updated | ## Endpoints | Verb | Route | Permission | What it does | |---|---|---|---| | GET | `/api/v1/notifications` | `Inbox.View` | Paginated inbox (`?unreadOnly=true` to filter) | | GET | `/api/v1/notifications/unread-count` | `Inbox.View` | Unread count for the badge | | POST | `/api/v1/notifications/{id}/read` | `Inbox.MarkRead` | Mark single notification read | | POST | `/api/v1/notifications/read-all` | `Inbox.MarkRead` | Mark every unread notification read | ## The billing email bridge Not every notification is an inbox row. The same module also hosts the **email** side of tenant lifecycle notifications: four handlers subscribe to the Billing module's integration events (`InvoiceIssuedIntegrationEvent`, `TenantNearingExpiryIntegrationEvent`, `TenantEnteredGraceIntegrationEvent`, `TenantExpiredIntegrationEvent`), resolve the tenant admin's address from the tenant store, and send templated emails through the Mailing building block's `IMailService`. Subjects and bodies live in `BillingEmailBodies.cs`; `BillingEmailSender.cs` wraps send + logging so a mail failure never breaks the originating billing operation. Same bridge pattern, different sink — keep both flavours here so source modules never know how a notification is delivered. ## How to extend ### Add a new notification type from another module 1. Define an integration event in your module's `*.Contracts` assembly. `IIntegrationEvent` is a flat contract — carry the envelope fields as record parameters: ```csharp // Modules.Tickets.Contracts/Events/TicketAssignedIntegrationEvent.cs public sealed record TicketAssignedIntegrationEvent( Guid Id, DateTime OccurredOnUtc, string? TenantId, string CorrelationId, string Source, Guid TicketId, string TicketNumber, string AssigneeUserId) : IIntegrationEvent; ``` 2. Publish it from the tickets module's `AssignTicketCommandHandler` via `IEventBus.PublishAsync`. 3. Add an integration-event handler **in the Notifications module**: ```csharp public sealed class TicketAssignedNotificationHandler(/* deps */) : IIntegrationEventHandler { public async Task HandleAsync(TicketAssignedIntegrationEvent evt, CancellationToken ct = default) { var notification = Notification.Create( userId: evt.AssigneeUserId, type: "ticket.assigned", title: $"Ticket {evt.TicketNumber} assigned to you", body: null, link: $"/tickets/{evt.TicketId}", source: evt.Source, metadata: new { evt.TicketId, evt.TicketNumber }); // ... write + push as in the mention handler } } ``` 4. The handler is auto-registered by the eventing module's assembly scan. ### Render type-specific notifications on the frontend `type` is a free-form string. In the dashboard's notification list, dispatch on `type` to pick the right icon and link template: ```ts const renderers = { 'chat.mention': (n) => , 'ticket.assigned': (n) => , // … }; ``` ### Suppress some notification types per user Add a `NotificationPreference` aggregate (per-user mute list of `Type` strings) and check it in each integration event handler before writing the row. A self-service "Settings → Notifications" page can toggle preferences. ## Tests - `src/Tests/Integration.Tests/Tests/Notifications/NotificationsEndpointTests.cs` — the four endpoints (list, unread count, mark-read, read-all). - Chat's `MentionAndNotificationTests.cs` (`src/Tests/Integration.Tests/Tests/Chat/`) asserts the full bridge: a mention produces an inbox row and a SignalR push. ## Related - [Chat module](/docs/modules/chat/) — the first event producer; `MentionedInChannelIntegrationEvent` is the canonical example. - [Eventing.Abstractions](/docs/building-blocks/eventing/) — the `IEventBus` / `IIntegrationEventHandler` contract used here. - [Architecture overview](/docs/architecture/) — module order and event-bus wiring. --- # Tickets module > A complete support-ticket workflow — auto-numbered tickets, status state machine (Open → InProgress → Resolved → Closed), comments, assignment, priority, edit, and soft delete. Source: https://fullstackhero.net/docs/modules/tickets/ The Tickets module is a small, clean reference of a domain with a real state machine. Tickets transition Open → InProgress → Resolved → Closed, comments are immutable once posted, and the aggregate refuses every illegal transition with a `Conflict` response. It's not a SaaS-helpdesk product (no SLAs, no escalation routing, no email-to-ticket gateway), it's the **state-machine plumbing** a support module needs before any of that layer goes on top. If you're adding a domain with strict status transitions (orders, applications, leases, courses), the `Ticket` aggregate is the cleanest example in the kit. Read `Modules.Tickets/Domain/Ticket.cs` end-to-end before writing your own. ## What ships in v10 - **Tickets** with auto-generated `Number` (sequential, tenant-scoped strings: `TK-1`, `TK-2`, …), title, optional description, status, priority (Low / Medium / High / Critical), reporter user, and optional assignee. - **Four-state status machine**: Open → InProgress → Resolved → Closed, with reopen-from-Resolved-or-Closed support. `Close` finalizes a resolved ticket (idempotent when already Closed; any other non-Resolved source state is rejected with 409). Every transition raises a `TicketStatusChangedDomainEvent`. - **Assignment workflow** — assigning when Open moves to InProgress; unassigning from InProgress moves back to Open; assigning a Resolved or Closed ticket is rejected with 409. - **Edit** — `Update` revises a ticket's title, description, and priority; a Closed ticket is frozen and must be reopened first (409). - **Immutable comments** owned by tickets. Closed tickets refuse new comments. - **Domain events** for cross-module reactions: `TicketCreatedDomainEvent`, `TicketAssignedDomainEvent`, `TicketStatusChangedDomainEvent`, `TicketCommentAddedDomainEvent`. - **Soft delete + restore** on tickets and comments via `ISoftDeletable` — `Delete` trashes a ticket (its comments survive and return on restore), `ListTrashed` lists the trash, `Restore` brings it back. The tenant dashboard's Trash page restores tickets from here (permission-gated tab). - **Search** by status, priority, assignee, and free text. - **10 fine-grained permissions** — View / Create / Update / Delete / Restore / Assign / Resolve / Reopen / Close / Comment. ## Architecture at a glance ``` src/Modules/Tickets/ ├── Modules.Tickets/ │ ├── TicketsModule.cs IModule entry — order 700 │ ├── Domain/ │ │ ├── Ticket.cs AggregateRoot, state machine, ISoftDeletable │ │ └── TicketComment.cs Owned by Ticket, ISoftDeletable │ ├── Data/ │ │ ├── TicketsDbContext.cs Tenant-aware │ │ ├── Configurations/ EF type configs │ │ └── TicketsDbInitializer.cs Seeds demo data │ ├── Features/v1/Tickets/ │ │ ├── CreateTicket/ │ │ ├── UpdateTicket/ │ │ ├── AssignTicket/ │ │ ├── ResolveTicket/ │ │ ├── ReopenTicket/ │ │ ├── CloseTicket/ │ │ ├── AddTicketComment/ │ │ ├── DeleteTicket/ │ │ ├── RestoreTicket/ │ │ ├── GetTicketById/ │ │ ├── ListTicketComments/ │ │ ├── SearchTickets/ │ │ └── ListTrashedTickets/ │ └── Events/ Domain event handlers └── Modules.Tickets.Contracts/ Public commands/queries/events ``` The module loads at order `700`, after Catalog (`600`). Tenant-aware: each tenant has its own ticket queue. ## The state machine ```csharp // src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs public sealed class Ticket : AggregateRoot, ISoftDeletable { public string Number { get; private set; } = default!; // "TK-1", "TK-2", … tenant-scoped public TicketStatus Status { get; private set; } public Guid? AssignedToUserId { get; private set; } public static Ticket Create( string number, string title, string? description, TicketPriority priority, Guid reporterUserId, Guid? assignedToUserId) { // A ticket assigned at creation jumps straight to InProgress. var initialStatus = assignedToUserId is not null ? TicketStatus.InProgress : TicketStatus.Open; var ticket = new Ticket { Id = Guid.CreateVersion7(), Number = number, Title = title.Trim(), // ... Status = initialStatus, AssignedToUserId = assignedToUserId, CreatedAtUtc = DateTime.UtcNow, }; ticket.AddDomainEvent(DomainEvent.Create( (id, ts) => new TicketCreatedDomainEvent( ticket.Id, ticket.Number, ticket.Title, ticket.Priority, ticket.ReporterUserId, ticket.AssignedToUserId, id, ts))); return ticket; } public void Assign(Guid? assigneeUserId) { ThrowIfClosedOrResolved("assign"); // 409 from Resolved or Closed if (assigneeUserId == AssignedToUserId) return; // idempotent var previous = AssignedToUserId; AssignedToUserId = assigneeUserId; // Picking up a ticket implicitly starts it; unassigning sends it back to Open. if (assigneeUserId is not null && Status == TicketStatus.Open) TransitionStatus(TicketStatus.InProgress); else if (assigneeUserId is null && Status == TicketStatus.InProgress) TransitionStatus(TicketStatus.Open); AddDomainEvent(DomainEvent.Create( (id, ts) => new TicketAssignedDomainEvent(Id, previous, assigneeUserId, id, ts))); } public void Resolve(string? resolutionNote) { if (Status == TicketStatus.Closed) throw new CustomException("A closed ticket cannot be resolved — reopen it first.", (IEnumerable?)null, HttpStatusCode.Conflict); if (Status == TicketStatus.Resolved) return; // idempotent ResolutionNote = string.IsNullOrWhiteSpace(resolutionNote) ? null : resolutionNote.Trim(); ResolvedAtUtc = DateTime.UtcNow; TransitionStatus(TicketStatus.Resolved); } public Guid AddComment(Guid authorUserId, string body) { if (Status == TicketStatus.Closed) throw new CustomException("A closed ticket cannot accept new comments — reopen it first.", (IEnumerable?)null, HttpStatusCode.Conflict); var comment = TicketComment.Create(Id, authorUserId, body); _comments.Add(comment); AddDomainEvent(DomainEvent.Create( (id, ts) => new TicketCommentAddedDomainEvent(Id, comment.Id, authorUserId, id, ts))); return comment.Id; } private void TransitionStatus(TicketStatus next) { if (next == Status) return; var previous = Status; Status = next; AddDomainEvent(DomainEvent.Create( (id, ts) => new TicketStatusChangedDomainEvent(Id, previous, next, id, ts))); } } ``` Every method that mutates state does three things consistently: validate the source state, mutate, raise the right domain event. That's the pattern to follow when you add new transitions. ## Public API Thirteen commands and queries in the Contracts assembly: | Type | Purpose | |---|---| | `CreateTicketCommand(title, description?, priority, assignedToUserId?)` | File a ticket; auto-numbered | | `UpdateTicketCommand(ticketId, title, description?, priority)` | Edit title, description, and priority (rejected on a Closed ticket) | | `AssignTicketCommand(ticketId, assigneeUserId?)` | Assign or unassign (null = unassign) | | `ResolveTicketCommand(ticketId, resolutionNote?)` | Mark resolved | | `ReopenTicketCommand(ticketId)` | Reopen a resolved/closed ticket | | `CloseTicketCommand(ticketId)` | Finalize a resolved ticket (Resolved → Closed) | | `AddTicketCommentCommand(ticketId, body)` | Post a comment | | `DeleteTicketCommand(ticketId)` | Soft-delete (trash) a ticket | | `RestoreTicketCommand(ticketId)` | Restore a soft-deleted ticket | | `GetTicketByIdQuery(ticketId)` | Fetch single ticket with comments | | `ListTicketCommentsQuery(ticketId)` | All comments for a ticket (404 if the ticket doesn't exist) | | `SearchTicketsQuery { search?, status?, priority?, assignedToUserId?, reporterUserId?, pageNumber, pageSize, sortBy?, sortDir? }` | Advanced search | | `ListTrashedTicketsQuery(pageNumber, pageSize)` | View soft-deleted tickets | Most commands return the ticket id (`ICommand`); `DeleteTicketCommand` returns `Unit`, queries return `TicketDto` / `TicketCommentDto` shapes. All are sealed records. ## Endpoints All under `/api/v1/tickets`, gated by the corresponding `Tickets.*` permission. | Verb | Route | Endpoint class | Permission | |---|---|---|---| | POST | `/` | `CreateTicketEndpoint` | `Tickets.Create` | | PUT | `/{ticketId}` | `UpdateTicketEndpoint` | `Tickets.Update` | | DELETE | `/{ticketId}` | `DeleteTicketEndpoint` | `Tickets.Delete` | | GET | `/{ticketId}` | `GetTicketByIdEndpoint` | `Tickets.View` | | GET | `/` | `SearchTicketsEndpoint` (filters via query string) | `Tickets.View` | | GET | `/trash` | `ListTrashedTicketsEndpoint` | `Tickets.Restore` | | POST | `/{ticketId}/assign` | `AssignTicketEndpoint` | `Tickets.Assign` | | POST | `/{ticketId}/resolve` | `ResolveTicketEndpoint` | `Tickets.Resolve` | | POST | `/{ticketId}/reopen` | `ReopenTicketEndpoint` | `Tickets.Reopen` | | POST | `/{ticketId}/close` | `CloseTicketEndpoint` | `Tickets.Close` | | POST | `/{ticketId}/restore` | `RestoreTicketEndpoint` | `Tickets.Restore` | | GET | `/{ticketId}/comments` | `ListTicketCommentsEndpoint` | `Tickets.View` | | POST | `/{ticketId}/comments` | `AddTicketCommentEndpoint` | `Tickets.Comment` | ## Domain events Four events ship with the module (each also carries an `EventId` and `OccurredOnUtc` from the `DomainEvent` base): - `TicketCreatedDomainEvent(TicketId, Number, Title, Priority, ReporterUserId, AssignedToUserId)` — raised by `Ticket.Create`. - `TicketAssignedDomainEvent(TicketId, PreviousAssigneeUserId, AssigneeUserId)` — raised by `Ticket.Assign`. - `TicketStatusChangedDomainEvent(TicketId, PreviousStatus, NewStatus)` — raised by **every** status transition, including the implicit Open ↔ InProgress moves that assignment triggers. - `TicketCommentAddedDomainEvent(TicketId, CommentId, AuthorUserId)` — raised by `Ticket.AddComment`. These are in-module domain events. To react from another module (e.g. send an email when a ticket is resolved), wrap them in an integration event and publish via `IEventBus`. ## Configuration - **`TicketsDbContext`** — schema `tickets`, tenant-aware, two tables (`Tickets`, `TicketComments`). - **Connection** — per-tenant via Finbuckle resolution. - **Demo seed** — `TicketsDbInitializer` creates a handful of tickets when `FSH.Starter.DbMigrator seed-demo` runs. - **Number generation** — handled in `CreateTicketCommandHandler`: sequential, tenant-scoped `TK-{n}` strings. The count includes soft-deleted tickets so a trashed number is never reused; racing writers collide on the unique index and get a retryable 409. ## How to extend ### Add a new state transition `Close` (Resolved → Closed) ships as a first-class command, endpoint, and `Tickets.Close` permission — `Ticket.Close()` validates the source state, stamps `ClosedAtUtc`, and raises `TicketStatusChangedDomainEvent`. Use it as the template for any new transition you need (e.g. an `Escalate` step): ```csharp // 1. Domain — guard the source state, mutate, raise the event (Modules.Tickets/Domain/Ticket.cs) public void Escalate() { if (Status is TicketStatus.Closed) throw new CustomException("Cannot escalate a closed ticket.", (IEnumerable?)null, HttpStatusCode.Conflict); Priority = TicketPriority.Critical; UpdatedAtUtc = DateTime.UtcNow; } // 2. Contracts — EscalateTicketCommand(Guid TicketId) : ICommand // 3. Feature folder — handler + {Command}Validator + endpoint with .RequirePermission(...) // 4. Register the endpoint in TicketsModule.MapEndpoints and add the permission constant ``` Follow the existing `CloseTicket` slice end-to-end — every command handler needs a `{Command}Validator` (enforced by `Architecture.Tests`). ### Notify the assignee `TicketAssignedDomainEvent` is an in-module domain event — it can't cross the module boundary. Bridge it: handle it in-module with a Mediator `INotificationHandler`, publish a `TicketAssignedIntegrationEvent` (declared in `Modules.Tickets.Contracts`) via `IEventBus`, then add an `IIntegrationEventHandler` in the Notifications module that writes the inbox row and pushes `NotificationCreated` over SignalR. The [Notifications module page](/docs/modules/notifications/) walks through exactly this example end-to-end. ### Add SLA timers Resolved tickets carry `ResolvedAtUtc`. Add an `ExpectedResolutionAtUtc` field set on Assign or Create, then write a Hangfire recurring job that scans for breached SLAs and raises an integration event. The Billing module's `MonthlyInvoiceJob` is a template for the job wiring. ## Tests Integration tests live at `src/Tests/Integration.Tests/Tests/Tickets/` and cover the lifecycle (create, search/filter, assign, resolve/reopen guards), the close/update/delete operations, the delete → restore round-trip (comments survive), and tenant isolation. If you customize the state machine, add unit tests on `Ticket` directly to cover every transition. ## Related - [Notifications module](/docs/modules/notifications/) — wire ticket events to real-time push. - [Architecture: vertical slice](/docs/architecture/vertical-slice/) — the feature-folder pattern. - [Modules overview](/docs/modules/) — the other nine modules. --- # Webhooks module > Tenant-scoped webhook subscriptions, open-generic event fan-out, HMAC-signed delivery with secrets encrypted at rest, permission-gated endpoints, Hangfire dispatch with exponential backoff, and a delivery audit log. Source: https://fullstackhero.net/docs/modules/webhooks/ The Webhooks module turns every integration event your kit publishes into an outbound HTTP delivery to subscribed tenants. Tenants register a URL + an event list (with `*` wildcard support), the module fans events using an open-generic `IIntegrationEventHandler<>`, signs payloads with HMAC-SHA256, and delivers via Hangfire jobs with exponential-backoff retry and a delivery log for audit. Around **1,100 lines of code in the runtime project**. You publish integration events anyway (Chat does, Identity does, Files does). The Webhooks module is the configuration layer that lets *your tenants* opt into those events being delivered to their systems — no per-event wiring on your side. ## What ships in v10 - **Tenant-scoped subscriptions** — each `WebhookSubscription` row belongs to a tenant and stores `Url`, `EventsCsv` (comma-separated list with `*` wildcard support), `SecretHash` (the HMAC signing secret, **encrypted at rest** — see below), `IsActive`. - **Signing secret encrypted at rest** — the secret is the HMAC key, so it must stay recoverable (a one-way hash would make signing impossible). It is encrypted with ASP.NET Data Protection (`IWebhookSecretProtector`) on create and decrypted only at sign time, so a database breach never exposes plaintext secrets. - **Permission-gated endpoints** — `Webhooks.View` / `Create` / `Delete` / `Test` (previously authentication-only). - **Open-generic event handler** — one `WebhookFanoutHandler` registered for every `IIntegrationEvent`; no per-event setup needed. When a new module publishes a new event, existing subscriptions automatically receive it. - **Tenant context restoration** — the fanout handler explicitly restores the tenant context before querying subscriptions, so Finbuckle's global query filter sees the right tenant. (This is the single most important gotcha — see below.) - **HMAC-SHA256 payload signing** — when `SecretHash` is set, every delivery includes `X-Webhook-Signature: sha256={hex}` so subscribers can verify authenticity. - **Hangfire delivery jobs** with `[AutomaticRetry]` — 5 attempts total, exponential backoff (30 s, 2 min, 10 min, 1 h). - **Transient vs permanent error classification** — 5xx, 408, 429 retried; other 4xx permanent. - **Delivery log** — every attempt (success or failure) appends a `WebhookDelivery` row with status code, duration, error. - **Five permission-gated endpoints** for tenants to manage subscriptions and read the delivery log. - **HTTP resilience** via a named `HttpClient` "Webhooks" using `Microsoft.Extensions.Http.Resilience` (Polly v8). ## Architecture at a glance ``` src/Modules/Webhooks/ ├── Modules.Webhooks/ ~1,100 LoC │ ├── WebhooksModule.cs IModule — order 400 │ ├── Domain/ │ │ ├── WebhookSubscription.cs Tenant-scoped, IsActive flag │ │ └── WebhookDelivery.cs Per-attempt row, immutable │ ├── Data/WebhookDbContext.cs Schema: webhooks │ ├── Services/ │ │ ├── WebhookFanoutHandler.cs Open-generic event bridge │ │ ├── WebhookDispatchJob.cs Hangfire job, HMAC + retry │ │ ├── WebhookDispatcher.cs EnqueueAsync into Hangfire │ │ ├── WebhookDeliveryService.cs One-shot delivery (test endpoint) │ │ ├── WebhookSecretProtector.cs DataProtection encrypt/decrypt │ │ └── WebhookPayloadSigner.cs HMAC-SHA256 hex │ └── Features/v1/ 5 endpoints └── Modules.Webhooks.Contracts/ Commands, queries, DTOs ``` The module loads at order `400` — after Files (`350`), before Catalog (`600`). Its `WebhookDbContext` is tenant-aware so subscriptions and delivery logs are isolated per tenant. ## The open-generic fan-out The single piece of code that makes the whole module work: ```csharp // src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs (simplified) public sealed class WebhookFanoutHandler : IIntegrationEventHandler where TEvent : IIntegrationEvent { // ctor injects WebhookDbContext, IWebhookDispatcher, IEventSerializer, // IMultiTenantContextAccessor, ILogger public async Task HandleAsync(TEvent @event, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(@event.TenantId)) return; // skip platform-wide events // Install the tenant context for the subscription read, restore the previous one after. var prev = _tenantContextAccessor.MultiTenantContext; try { var info = new AppTenantInfo(@event.TenantId, @event.TenantId); ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = new MultiTenantContext(info); var eventType = typeof(TEvent).Name; var subscriptions = await _db.Subscriptions .AsNoTracking() .Where(s => s.IsActive) .ToListAsync(ct).ConfigureAwait(false); var matching = subscriptions.Where(s => s.MatchesEvent(eventType)).ToList(); if (matching.Count == 0) return; var payload = _serializer.Serialize(@event); foreach (var subscription in matching) await _dispatcher .EnqueueAsync(@event.TenantId, subscription.Id, eventType, payload, ct) .ConfigureAwait(false); // one bad enqueue is caught + logged, fan-out continues } finally { ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = prev; } } } ``` Registered open-generic in `ConfigureServices`: ```csharp services.AddScoped(typeof(IIntegrationEventHandler<>), typeof(WebhookFanoutHandler<>)); ``` The DI container materializes the handler for every event type the eventing system sees. Zero per-event code. The handler installs the event's tenant into `IMultiTenantContextSetter` before querying subscriptions (and restores the previous context after). Without it, the Finbuckle query filter on `WebhookDbContext` filters away every row because the tenant context is null on the background event-bus thread. **This is the easiest thing to forget when adding a new handler.** ## The delivery job ```csharp // src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs (simplified) public sealed class WebhookDispatchJob { // ctor injects IServiceScopeFactory, IHttpClientFactory, IWebhookSecretProtector, ILogger // 4 retries after the initial attempt → up to 5 total. Backoff: 30s, 2m, 10m, 1h. [AutomaticRetry(Attempts = 4, DelaysInSeconds = new[] { 30, 120, 600, 3600 }, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task DispatchAsync( Guid subscriptionId, string tenantId, string eventType, string payloadJson, PerformContext? context, CancellationToken ct) { // Fresh scope: set the tenant context FIRST, then resolve WebhookDbContext so its // Finbuckle filter sees a real TenantInfo (same pattern as SqlAuditSink). using var scope = _scopeFactory.CreateScope(); /* resolve tenant from store, set IMultiTenantContextSetter, load subscription */ var attemptNumber = (context?.GetJobParameter("RetryCount") ?? 0) + 1; var delivery = WebhookDelivery.Create(subscriptionId, eventType, payloadJson, attemptNumber); using var content = new StringContent(payloadJson, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); var signingSecret = _secretProtector.Unprotect(subscription.SecretHash); // decrypt at sign time if (!string.IsNullOrEmpty(signingSecret)) content.Headers.Add("X-Webhook-Signature", WebhookPayloadSigner.Sign(payloadJson, signingSecret)); content.Headers.Add("X-Webhook-Event", eventType); content.Headers.Add("X-Webhook-Delivery-Id", delivery.Id.ToString()); var response = await _httpClientFactory.CreateClient("Webhooks") .PostAsync(new Uri(subscription.Url), content, ct).ConfigureAwait(false); delivery.RecordResult((int)response.StatusCode, response.IsSuccessStatusCode, null); /* save delivery row */ if (response.IsSuccessStatusCode) return; if (IsTransient((int)response.StatusCode)) throw new WebhookDeliveryFailedException(/* … */); // throw → Hangfire retries // Permanent client error (4xx other than 408/429) — log it, give up, no exception. } private static bool IsTransient(int statusCode) => statusCode >= 500 || statusCode == 408 || statusCode == 429; } ``` `IsTransient` decides between 5xx / 408 / 429 (throw → retry) and other 4xx (give up quietly). Network errors, DNS failures, and timeouts count as transient too. Each attempt — successful or failed — appends a `WebhookDelivery` row to the log with its own attempt number. ## Public API | Type | Purpose | |---|---| | `CreateWebhookSubscriptionCommand(url, events[], secret?)` | Subscribe (the secret is encrypted before storage) | | `DeleteWebhookSubscriptionCommand(id)` | Delete the subscription | | `TestWebhookSubscriptionCommand(id)` | Send a synchronous `webhook.test` delivery (signed if a secret is set) | | `GetWebhookSubscriptionsQuery(paging)` | List subscriptions for caller's tenant | | `GetWebhookDeliveriesQuery(subscriptionId, paging)` | Delivery log for one subscription | ## Permissions Every endpoint is gated by a `Webhooks.*` permission (defined in `Modules.Webhooks.Contracts/Authorization/WebhooksPermissions.cs` and registered at module startup). `View` is a basic permission; the rest are assigned per role. | Permission | Grants | |---|---| | `Webhooks.View` | List subscriptions and read the delivery log | | `Webhooks.Create` | Create a subscription | | `Webhooks.Delete` | Delete a subscription | | `Webhooks.Test` | Send a one-off test delivery | Before this release the endpoints were authentication-only — any signed-in user could manage every webhook in their tenant. They now require explicit `Webhooks.*` permissions, so add them to the roles that should manage webhooks or those users will get `403`. ## Endpoints | Verb | Route | What it does | Permission | |---|---|---|---| | POST | `/api/v1/webhooks/subscriptions` | Create subscription | `Webhooks.Create` | | DELETE | `/api/v1/webhooks/subscriptions/{id}` | Delete subscription | `Webhooks.Delete` | | GET | `/api/v1/webhooks/subscriptions` | List subscriptions | `Webhooks.View` | | GET | `/api/v1/webhooks/subscriptions/{id}/deliveries` | Delivery log (newest first) | `Webhooks.View` | | POST | `/api/v1/webhooks/subscriptions/{id}/test` | Test delivery | `Webhooks.Test` | ## Headers on every delivery | Header | Value | |---|---| | `Content-Type` | `application/json` | | `X-Webhook-Event` | The event type name (e.g. `ProductRepricedIntegrationEvent`) | | `X-Webhook-Delivery-Id` | The `WebhookDelivery` row id — fresh per attempt, so receivers can dedupe | | `X-Webhook-Signature` | `sha256={hex}` (only if `SecretHash` is set on the subscription) | Subscribers verify the signature by HMAC-SHA256-hashing the raw request body with the same secret and comparing. ## How to extend ### Verify signatures on the subscriber side (.NET) ```csharp public static bool VerifyWebhook(string body, string signatureHeader, string secret) { if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256=", StringComparison.Ordinal)) return false; var expected = signatureHeader["sha256=".Length..]; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); var actual = Convert.ToHexStringLower(hash); return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual)); } ``` ### Add per-subscription rate-limiting The dispatcher queues all matching subscriptions on Hangfire. To throttle high-volume subscribers, wrap `IWebhookDispatcher.EnqueueAsync` with a per-subscription rate limiter (e.g. `System.Threading.RateLimiting`) and delay-enqueue when over budget. ### Hide private payload fields per subscription Pre-serialize via a projection by subscription policy. The simplest approach is to accept a `payloadProjector` Func on the subscription and serialize using it before enqueue. More involved: a per-event projector contract resolved from DI. ### Purge old delivery logs The kit doesn't auto-purge the `WebhookDelivery` table. For high-volume tenants, add a Hangfire recurring job that deletes rows older than N days. Pattern: copy `AuditRetentionJob` from the Auditing module. ## Tests The module ships unit tests in `src/Tests/Webhooks.Tests/` (domain, validators, the fan-out handler, payload signer, and the secret protector — encryption round-trips and never stores plaintext) and end-to-end coverage in `src/Tests/Integration.Tests/Tests/Webhooks/` (subscription CRUD, signed delivery, dispatch outcomes/retry, pagination, and cross-tenant isolation) using a controllable in-process HTTP receiver. ## Related - [Eventing building block](/docs/building-blocks/eventing/) — `IIntegrationEvent`, `IEventBus`, and the open-generic handler registration pattern. - [Multitenancy module](/docs/modules/multitenancy/) — the tenant context restore the fanout handler depends on. - [Cross-cutting concerns](/docs/cross-cutting-concerns/) — Hangfire retry policy, HttpClient resilience, idempotency. --- # Overview > Authentication, authorization, tenant isolation, impersonation, 2FA, rate limits, webhook signing, data protection, CORS — the security surface in one map. Source: https://fullstackhero.net/docs/security/ Security in fullstackhero is **layered defence with sensible defaults**. JWT bearer with rotating refresh tokens; fine-grained permission gates on every endpoint (wired as both the default and fallback authorization policy, so nothing fails open); ASP.NET Identity for the user store; multi-tenant isolation as the default behaviour of every query; rate limiting on auth-flow endpoints; HMAC-signed webhooks with secrets encrypted at rest; Valkey-backed data protection keys; audit on every state change; JSON masking for sensitive payloads. The kit ships secure defaults, but several things you must configure correctly for production: `JwtOptions:SigningKey` (set via secrets manager — startup refuses an empty or placeholder key), `MailOptions` (credentials never in git), `S3StorageOptions` (use IAM roles where possible), `RateLimitingOptions` (verify it's enabled), the CORS origin allowlist, and security headers. The [production checklist](/docs/security/production-checklist/) covers all of them. ## The pillars Each page covers one concern in depth. ## Related - [Multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — tenant isolation discipline. - [Identity module](/docs/modules/identity/) — the authentication + permission implementation. - [Auditing module](/docs/modules/auditing/) — capture + masking + retention. - [Webhooks module](/docs/modules/webhooks/) — HMAC signing. - [Rate limiting (cross-cutting)](/docs/cross-cutting-concerns/rate-limiting/) — the auth-policy mechanics. --- # Authentication > JWT bearer with rotating refresh tokens, ASP.NET Identity user store, lockout, email confirmation, per-device session tracking, and the auth-flow rate-limit policy. Source: https://fullstackhero.net/docs/security/authentication/ The Identity module owns authentication. Login goes through `POST /api/v1/identity/token/issue` and returns an access token (45 minutes in the shipped config) and a refresh token (7 days). The access token is signed with `JwtOptions:SigningKey`; the refresh token is stored as a SHA-256 hash on the user and **rotated on every refresh** to limit replay damage if it leaks. Both endpoints (and every other auth-flow endpoint) carry the kit's `auth` rate-limit policy. Set `JwtOptions:SigningKey` via a secrets manager — never check it into git. The repo ships a well-known dev-only key in `appsettings.Development.json`; the base and production config leave it empty, and startup validation **refuses to boot** with an empty key, a key under 32 characters, or the sample `replace-with-…` placeholder. That guard exists so a forgotten key can't silently make your tokens forgeable. ## What ships - **JWT bearer** via `Microsoft.AspNetCore.Authentication.JwtBearer`. - **ASP.NET Identity** on top of `FshUser : IdentityUser` and `FshRole : IdentityRole`. - **Refresh tokens** stored hashed (SHA-256) in `FshUser.RefreshToken` with `RefreshTokenExpiryTime`; rotated on each `/token/refresh`. - **Per-device sessions** — every login opens a `UserSession` (IP, user agent, device/browser/OS) keyed to the refresh-token hash; sessions can be listed and revoked, including admin revoke-all. - **Lockout** — 5 consecutive failed attempts triggers a 15-minute lockout. - **Password rules** — 10-character minimum with digit, lowercase, and uppercase required (`IdentityOptions`); history, expiry, and warning windows via `PasswordPolicy:*`. - **Email confirmation** — new users start with `EmailConfirmed = false` and cannot mint tokens until the confirm-email flow completes. - **Two-factor TOTP** — opt-in per user; once enrolled, `token/issue` requires the TOTP code (see [two-factor authentication](/docs/security/two-factor/)). - **Rate-limited endpoints** — token issue, refresh, confirm-email, resend-confirmation, forgot-password, reset-password, and self-register all carry the `auth` policy (10/min per user-or-IP by default — note `RateLimitingOptions:Enabled` is `false` in the base config and `true` in `appsettings.Production.json`). ## Configuration ```jsonc { "JwtOptions": { "Issuer": "fsh.local", "Audience": "fsh.clients", "SigningKey": "", // set via secrets manager / env var "AccessTokenMinutes": 45, "RefreshTokenDays": 7 }, "PasswordPolicy": { "PasswordHistoryCount": 5, "PasswordExpiryDays": 90, "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true } } ``` For `JwtOptions:SigningKey`, use 32+ random bytes encoded in base64: ```bash openssl rand -base64 32 ``` Store it in your platform's secrets system (Azure Key Vault, AWS Secrets Manager, GitHub Secrets, Doppler, etc.) and inject via environment variable (`JwtOptions__SigningKey` with double underscore). ## The token pipeline ``` POST /api/v1/identity/token/issue └─ rate-limited (auth policy) └─ Lockout check, then UserManager.CheckPasswordAsync └─ 2FA check (if enrolled) └─ User active + email-confirmed check; tenant active + validity check └─ ITokenService.IssueAsync → JWT access + refresh pair └─ Store hashed refresh token; open a UserSession (per-device tracking) └─ Security audit (LoginSucceeded / TokenIssued) + outbox integration event └─ Return TokenResponse ``` The access token carries the standard claims plus: - `sub` / `email` / `name` — RFC 7519 short forms (legacy `ClaimTypes.*` equivalents are also emitted) - `tenant` — the resolved tenant at login time - `fullName`, `image_url` — display conveniences - **role claims** — the user's direct roles plus any roles inherited via groups Note what's *not* there: permissions. Permission checks resolve server-side from a cached per-user permission set — see [authorization](/docs/security/authorization/). That keeps tokens small and lets permission changes apply to live sessions. ## Refresh token rotation `POST /api/v1/identity/token/refresh` takes the expiring access token plus the refresh token. The handler: 1. Hashes the presented refresh token and looks up the matching user; rejects if expired. 2. Validates the **session** tied to that refresh-token hash — a revoked session kills the refresh even if the token itself is still valid. 3. Cross-checks the access token's subject against the refresh token's owner. 4. Issues a fresh access **and** refresh token, overwrites the stored hash, and re-keys the session to the new refresh-token hash. Presenting the same refresh token twice fails with 401 on the second attempt — a signal that either replay-after-rotation happened, or your client is misconfigured (sending the same token in parallel). Every rotation is recorded in the security audit log (`TokenRevoked: RefreshTokenRotated`, then `TokenIssued`). ## Sessions and revocation Each login creates a `UserSession` row: refresh-token hash, IP, user agent, parsed device/browser/OS, created/last-activity/expiry timestamps. Endpoints (under `/api/v1/identity`): | Verb | Route | What | |---|---|---| | GET | `/sessions/me` | The current user's sessions | | POST | `/sessions/revoke-all` | Revoke all of the current user's sessions | | GET | `/sessions` | All sessions in the tenant (admin) | | GET | `/users/{userId}/sessions` | One user's sessions (admin) | | POST | `/users/{userId}/sessions/revoke-all` | Admin kill switch for a user | Revocation is enforced at **refresh time**: a revoked session can't mint new tokens, but an already-issued access token stays valid until it expires (up to `AccessTokenMinutes`). Keep access tokens short if that window matters to you. ## Email confirmation flow Newly registered users have `EmailConfirmed = false`. Token issuance rejects them with 401: ``` 1. POST /api/v1/identity/self-register → user created, confirm-email link mailed 2. User clicks link → GET /api/v1/identity/confirm-email?userId=…&code=…&tenant=… 3. User can now POST /token/issue → access + refresh ``` Admins can also confirm on a user's behalf via the admin-confirm-email endpoint. ## Lockout and brute-force defence Lockout is configured in `IdentityOptions` (in `IdentityModule`): - Lockout after **5** consecutive failed password attempts. - Lockout duration: **15 minutes**. - The lockout check runs **before** the password check, so an attacker can't distinguish a locked account from a wrong password. - A successful login resets the failed-attempt counter. - A locked account gets HTTP **423 Locked**, not a generic 401. This stacks with the [rate-limit `auth` policy](/docs/cross-cutting-concerns/rate-limiting/) — rate limiting throttles abusive callers regardless of identity; lockout protects individual user accounts even from low-rate but persistent attackers. ## What authentication doesn't do - **Single sign-on (SSO).** No OIDC / SAML / OAuth provider integration in v10. JWT bearer is the only token type. SSO is the most-requested roadmap item; if you need it now, layer an external IdP (Keycloak, Azure AD B2C, Auth0) in front and accept the IdP's JWTs. - **Passwordless / magic links.** No built-in passwordless flow. The `forgot-password` flow uses a token-via-email; you could repurpose it but it's not a first-class passwordless mode. - **WebAuthn / passkeys.** No passkey support in v10. Roadmap. ## Related - [Authorization](/docs/security/authorization/) — what gets gated after authentication. - [Two-factor authentication](/docs/security/two-factor/) — TOTP enrolment + verification. - [Impersonation](/docs/security/impersonation/) — operator impersonation grants. - [Identity module](/docs/modules/identity/) — the full implementation. - [Rate limiting](/docs/cross-cutting-concerns/rate-limiting/) — the auth-flow throttle. --- # Authorization > Permission-based authorization — fine-grained gates on every endpoint via .RequirePermission() with a flat registry, default + fallback policy wiring, and the silent-no-op gotcha that catches every team once. Source: https://fullstackhero.net/docs/security/authorization/ fullstackhero uses **permission-based authorization** with explicit gates on every endpoint — not blanket role checks. Roles are groupings of permissions; permissions are the unit of authorization. Users belong to roles (and optionally groups); roles carry permissions; endpoints check permissions. ```csharp endpoints.MapDelete("/products/{productId:guid}", handler) .RequirePermission(CatalogPermissions.Products.Delete); ``` That indirection — user → role → permission → endpoint — is what makes "give one user delete-but-not-create" trivial. Adjust the user's role's permission set; no code change. `RequiredPermissionAttribute` lives in the Shared building block and implements `IRequiredPermissionMetadata`. If you copy or re-declare the attribute in another assembly **without implementing that interface**, every `.RequirePermission()` call silently stops gating — the build passes, requests succeed, **no authorization happens**. This is the nastiest failure mode in the kit's auth pipeline. Always import the attribute from the Shared block. ## The policy wiring — default AND fallback The permission gate is a single authorization policy named `RequiredPermission`. The Identity module registers it and then wires it as **both** the `DefaultPolicy` **and** the `FallbackPolicy`: ```csharp // Modules.Identity/Authorization/Jwt/JwtAuthenticationExtensions.cs services.AddAuthorizationBuilder().AddRequiredPermissionPolicy(); services.AddAuthorization(options => { options.DefaultPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName)!; options.FallbackPolicy = options.GetPolicy(RequiredPermissionDefaults.PolicyName); }); ``` Both assignments matter, and the second one is the lesson every team learns the hard way: - **FallbackPolicy** covers endpoints with no auth metadata at all — nothing slips through unauthenticated just because someone forgot to annotate it. - **DefaultPolicy** covers endpoints that opt in via `.RequireAuthorization()` — including the module route groups (Catalog, Billing, Chat, Files, …). Without it, a group-level `.RequireAuthorization()` attaches ASP.NET Core's built-in authenticated-only default policy, which **suppresses the fallback** — so `.RequirePermission(...)` was never evaluated and any authenticated tenant member could perform gated writes. That fail-open was a real bug, fixed in PR #1290 and pinned by an integration regression test (`CreateProduct_Should_Return403_When_AuthenticatedUserLacksPermission`). If you fork the auth setup, keep both lines. The policy itself requires an authenticated user (JWT bearer scheme) plus the permission requirement. ## How a permission flows through 1. **Declared** as a constant in a module's `*.Contracts/Authorization/{Module}Permissions.cs`, alongside an `FshPermission` record for the registry. 2. **Registered** via `PermissionConstants.Register(MyPermissions.All)` in the module's `ConfigureServices`. 3. **Assigned** to a role as a role claim (`ClaimType = "permission"`) — via the role-permissions admin endpoint or the startup syncer. 4. **Granted** to a user by putting the user in that role. 5. **Checked** at the endpoint: `.RequirePermission(perm)` attaches `RequiredPermissionAttribute` metadata; `RequiredPermissionAuthorizationHandler` resolves the **current user's effective permission set server-side** and verifies it contains the required permission. The permission set is *not* a JWT claim. The JWT carries the user's **roles**; the handler resolves roles → permission claims from the database, cached in HybridCache (1 hour in Redis, 2 minutes in-process). That means permission changes take effect on live sessions without re-issuing tokens — see "When changes take effect" below. ## Declaring a permission ```csharp // src/Modules/Catalog/Modules.Catalog.Contracts/Authorization/CatalogPermissions.cs public static class CatalogPermissions { public static class Products { public const string Resource = "Catalog.Products"; public const string View = $"Permissions.{Resource}.View"; public const string Create = $"Permissions.{Resource}.Create"; public const string Update = $"Permissions.{Resource}.Update"; public const string Delete = $"Permissions.{Resource}.Delete"; } // ... Brands, Categories public static IReadOnlyList All { get; } = [ new("View Products", ActionConstants.View, Products.Resource, IsBasic: true), new("Create Products", ActionConstants.Create, Products.Resource), new("Update Products", ActionConstants.Update, Products.Resource), new("Delete Products", ActionConstants.Delete, Products.Resource), // ... ]; } ``` Two shapes, one name. The `const string` is what endpoints reference; the `FshPermission(Description, Action, Resource)` record is what the registry holds — its `Name` is computed as `Permissions.{Resource}.{Action}` and must match the constant. The flags matter: `IsBasic: true` permissions are granted to the built-in Basic role, `IsRoot: true` ones are reserved for the root tenant's Admin. ## Registering during module startup ```csharp public void ConfigureServices(IHostApplicationBuilder builder) { PermissionConstants.Register(CatalogPermissions.All); // ... other registrations } ``` `PermissionConstants.Register` dedupes by `Name`. Registering twice is a no-op. There's no removal API — once registered, a permission is part of the runtime for the process lifetime. ## Applying a gate The fluent helper is the canonical way: ```csharp endpoints.MapPost("/products", handler) .RequirePermission(CatalogPermissions.Products.Create); ``` The equivalent attribute form: ```csharp [RequiredPermission(CatalogPermissions.Products.Create)] ``` Both produce the same metadata — `.RequirePermission()` is literally `WithMetadata(new RequiredPermissionAttribute(...))`. `RequiredPermissionAuthorizationHandler` reads it from the endpoint's metadata via the `IRequiredPermissionMetadata` interface and checks the user's effective permission set. Endpoints with **no** permission metadata pass the handler (authentication is still enforced by the policy itself). ## Roles, groups, and aggregation - **Roles** are flat (no inheritance hierarchy). A user can be in multiple roles; the user's effective permission set is the **union** of every role's permission claims. - **Groups** are user collections that can carry roles. A user in a group inherits the group's roles — those roles show up as role claims in the user's JWT alongside their directly assigned roles. - The built-in roles are `Admin` and `Basic`. Admin gets every non-root permission (the root tenant's Admin also gets the `IsRoot` ones); Basic gets the `IsBasic` subset. ## When changes take effect Effective permissions are resolved server-side and cached per user (HybridCache: 1 h distributed, 2 min local). The kit invalidates that cache aggressively: - **Updating a role's permissions** invalidates the cache for every user holding that role — directly or via group membership (`InvalidateAffectedUsersAsync`). - **Assigning/removing a user's roles** invalidates that user's entry. So a permission grant or revocation applies to live sessions within seconds — no token re-issue needed, because permissions were never baked into the token. Role *claims* in the JWT, by contrast, are a login-time snapshot; they refresh on the next token issue. ## The role-permission syncer `RolePermissionSyncer` (driven by `RolePermissionSyncHostedService` at startup) walks every tenant and inserts any **newly registered** permissions that are missing from the built-in `Admin` / `Basic` roles' claim rows. It's idempotent — only missing claims are added — and if it writes anything it drops the per-user permission cache so logged-in sessions pick the new permissions up immediately. This is what makes "add a permission to a module, restart, Admin can use it" work without a manual seeding step. Custom roles are yours to manage — the syncer only touches the built-in two. ## Anonymous endpoints Because the fallback policy gates everything by default, anonymous endpoints must opt out explicitly: ```csharp endpoints.MapPost("/self-register", handler) .AllowAnonymous(); ``` Separately, `PathAwareAuthorizationHandler` (an `IAuthorizationMiddlewareResultHandler`) lets the `/scalar`, `/openapi`, and `/favicon.ico` paths through the authorization middleware so the API docs UI works without a token. ## Auditing authorization activity The Auditing module's security log (`/api/v1/audits/security`, gated by the audit-trail view permission) captures logins, token issuance/revocation, and impersonation events out of the box. The `SecurityAction` enum also defines `PermissionDenied` / `PolicyFailed` (default severity: Warning) for your own handlers to write via the audit client: ```csharp await Audit.ForSecurity(SecurityAction.PermissionDenied) .WithUser(currentUser.GetUserId()) .WithSecurityContext(ipAddress, userAgent) .WriteAsync(ct); ``` The kit does **not** automatically write an audit row for every 403 — if you want a denial trail, emit it where you enforce custom checks. ## The silent-no-op gotcha (again) The kit's `RequiredPermissionAttribute` implements `FSH.Framework.Shared.Identity.Authorization.IRequiredPermissionMetadata`. The `RequiredPermissionAuthorizationHandler` finds the metadata via interface lookup. If a duplicate `RequiredPermissionAttribute` lives in another assembly **without** implementing the interface, the handler finds nothing on the endpoint's metadata collection and treats it as unprotected. Symptoms: - Build passes. - Endpoint responds normally to authenticated requests. - Authorization is effectively off — any authenticated user can hit the endpoint. Always import `RequiredPermissionAttribute` from `FSH.Framework.Shared.Identity.Authorization`. Never declare a same-named local copy. ## Related - [Identity module](/docs/modules/identity/) — role + permission management endpoints. - [Shared building block](/docs/building-blocks/shared/) — `PermissionConstants`, `FshPermission`, `RequiredPermissionAttribute`. - [Authentication](/docs/security/authentication/) — issuing the JWT whose identity the permission check resolves. - [Auditing module](/docs/modules/auditing/) — the security event log. --- # CORS & security headers > CORS-before-HTTPS-redirect ordering, the SignalR-credentialed-CORS gotcha, and the production security headers the kit emits by default. Source: https://fullstackhero.net/docs/security/cors-and-headers/ CORS in fullstackhero has two non-default conventions: **CORS middleware runs before HTTPS redirect** (preflight requests can't follow redirects), and **credentialed CORS uses `SetIsOriginAllowed`** rather than `AllowAnyOrigin` (the latter silently breaks SignalR). Security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy) ship with sensible defaults and a few configurable knobs. `AllowAnyOrigin()` works for REST and silently breaks SignalR. Credentialed requests (which SignalR's negotiate always is) reject any wildcard origin per the CORS spec. The kit's dev pattern is `SetIsOriginAllowed(_ => true) + AllowCredentials()` — it echoes the actual request origin instead of `*` — and an explicit origin allowlist in production. **Never** mix `AllowAnyOrigin()` with realtime. ## How the kit wires CORS `AddHeroCors` binds `CorsOptions` from configuration and registers a single global policy (`FSHCorsPolicy`): ```jsonc { "CorsOptions": { "AllowAll": false, "AllowedOrigins": [ "https://app.example.com", "https://admin.example.com" ], "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] } } ``` - `AllowAll: true` → `SetIsOriginAllowed(_ => true)` + any header + any method + `AllowCredentials()`. Dev only. - `AllowAll: false` → `WithOrigins/WithHeaders/WithMethods` from the three lists + `AllowCredentials()`. Startup validation **fails** if any of the three lists is empty while `AllowAll` is false. - Credentials are always allowed by the policy — there is no `AllowCredentials` config key. - If `AllowAll` is false **and** `AllowedOrigins` is empty, CORS isn't mounted at all — cross-origin browser calls will simply fail. (`appsettings.Production.json` ships with an empty list precisely so you have to fill it in.) `UseHeroPlatform` mounts the middleware **before HTTPS redirect**, because OPTIONS preflight requests cannot follow HTTP→HTTPS redirects per the Fetch spec — the redirect breaks the preflight and the actual request never goes out. Pipeline order (relevant slice): ``` 1. UseExceptionHandler 2. UseResponseCompression 3. UseCors ← before HTTPS redirect 4. UseHttpsRedirection 5. Security headers 6. ... ``` ## Why not AllowAnyOrigin for SignalR CORS spec says: when a response has `Access-Control-Allow-Credentials: true`, the `Access-Control-Allow-Origin` must be an explicit origin, not `*`. SignalR's negotiate request is credentialed (it carries `Cookie` or the JWT via `accessTokenFactory`'s query-param fallback). With `AllowAnyOrigin()`, the server emits `Allow-Origin: *`, which violates the spec — the browser silently refuses to use the response, and SignalR's `HubConnection` fails to start with a confusing CORS error. `SetIsOriginAllowed(origin => true)` echoes the actual origin back in `Allow-Origin`, satisfying the spec, while accepting every origin in practice (which is what dev wants). ## Security headers `SecurityHeadersMiddleware` emits the following on every response (except excluded paths): | Header | Value | Purpose | |---|---|---| | `X-Content-Type-Options` | `nosniff` | Prevent MIME-type sniffing | | `X-Frame-Options` | `DENY` | Prevent clickjacking via iframe | | `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage | | `X-XSS-Protection` | `0` | Explicitly disable the legacy XSS auditor (modern guidance — CSP does this job) | | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` (HTTPS requests only) | Enforce HTTPS | | `Content-Security-Policy` | composed default, see below | Restrict what scripts, frames, images can load | The CSP default (set only if nothing upstream already set one): ``` default-src 'self'; img-src 'self' data: https:; script-src 'self' https: {ScriptSources}; style-src 'self' 'unsafe-inline' {StyleSources}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; ``` Tune via `SecurityHeadersOptions` — the CSP isn't free-form config; you get these knobs: ```jsonc { "SecurityHeadersOptions": { "Enabled": true, "ExcludedPaths": [ "/scalar", "/openapi" ], // docs UI manages its own scripts/styles "AllowInlineStyles": true, // drops 'unsafe-inline' from style-src when false "ScriptSources": [], // extra origins appended to script-src "StyleSources": [] // extra origins appended to style-src } } ``` If you embed third-party scripts (analytics, customer-support widgets), add the origin to `ScriptSources`. For a different policy shape entirely (e.g. a `connect-src` restriction), set the `Content-Security-Policy` header yourself earlier in the pipeline — the middleware respects an existing header. Test with browser dev tools open — CSP violations log to the console. ## Sub-resource integrity CSP doesn't enforce that third-party scripts haven't been tampered with. For the few external scripts you'd ship (analytics, payment SDK), add `integrity` attributes: ```html ``` The browser verifies the hash before executing. ## Cookies (if you swap JWT for cookie auth) The kit defaults to JWT bearer (cookies not used for auth). If you fork to cookie auth, the standard hardening applies: ```csharp services.ConfigureApplicationCookie(o => { o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; // or Lax for cross-site form-post flows o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.Name = "fsh.auth"; }); ``` The cookie should be HttpOnly (no JS access — limits XSS impact), Secure (HTTPS only), and SameSite=Strict for the strongest CSRF defence. ## Common mistakes - **Setting `AllowAll = true` in production.** CORS exists to give browsers a sanity check on cross-origin calls. Opening to the world removes the check (it doesn't directly compromise auth — auth still gates the request — but it removes the browser-enforced "is this site allowed to call you?" layer). - **Forgetting to fill `AllowedOrigins` in production.** With `AllowAll: false` and no origins, CORS isn't mounted — your React apps on other origins will get blocked by the browser. The symptom is "works in Postman, fails in the browser". - **Missing HSTS.** Without HSTS, an attacker on the network can downgrade to HTTP for the first request. The kit emits it on HTTPS responses automatically; verify your proxy doesn't strip it. - **CSP that breaks the UI.** If a third-party widget breaks after tightening CSP, look at the browser console — CSP violations are logged. Add the needed origins to `ScriptSources`/`StyleSources`, don't disable the middleware. ## Related - [Production checklist](/docs/security/production-checklist/) — header + CORS hardening items. - [Web building block](/docs/building-blocks/web/) — `AddHeroPlatform` + `UseHeroPlatform` registration. - [Realtime](/docs/cross-cutting-concerns/realtime/) — SignalR + credentialed CORS. --- # Data protection > Valkey-backed Data Protection key persistence so cookies, antiforgery tokens, and IDataProtector payloads survive rolling deploys across instances. Source: https://fullstackhero.net/docs/security/data-protection/ ASP.NET Core's Data Protection API encrypts cookies, antiforgery tokens, and any payload you ask it to with `IDataProtector`. Default behaviour stores the keys in the local filesystem — fine for a single-process host, broken for multi-instance deployments where each instance maintains its own key ring and decryption fails across nodes. The kit fixes this by **persisting the key ring to Valkey** (a Redis-compatible, BSD-licensed Redis fork) through the shared multiplexer. `AddHeroCaching` registers an `IConnectionMultiplexer` against Valkey, then `services.AddDataProtection().PersistKeysToStackExchangeRedis(...)` reuses the same multiplexer for key persistence. One Valkey connection pool, one key ring shared across every instance. ## What gets protected `IDataProtector` is used implicitly by: - **ASP.NET Identity cookie auth** — if you swap JWT for cookies in your fork, those cookies are Data-Protection-encrypted. - **Antiforgery tokens** — `[ValidateAntiForgeryToken]` on form posts uses Data Protection. - **Token providers** in `UserManager` — email confirmation tokens, password reset tokens. These are encrypted+signed with Data Protection. - **Webhook signing secrets** — the Webhooks module's `WebhookSecretProtector` encrypts each subscription's HMAC secret at rest (purpose string `FSH.Webhooks.SubscriptionSecret.v1`) and decrypts it only at dispatch time. See [webhook signing](/docs/security/webhook-signing/). - **Custom `IDataProtector` consumers** — anything you write that calls `services.GetRequiredService().CreateProtector("MyPurpose").Protect(...)`. In the kit's default JWT-bearer mode, the most visible payloads are the **email confirmation tokens** + **password reset tokens** sent in emails. If those don't decrypt on the next instance the user lands on, the link is dead. ## How it's wired ```csharp // AddHeroCaching (simplified) — only when CachingOptions:Redis is set var redis = ConnectionMultiplexer.Connect(opts.Redis); services.AddSingleton(redis); services.AddDataProtection() .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys") .SetApplicationName("FSH.Starter"); ``` Two important details: - **Same multiplexer** as the cache + (optionally) SignalR backplane. One connection pool covers all three. - **`SetApplicationName`** — Data Protection isolates keys by application name. If you run two fullstackhero hosts against the same Valkey (staging + dev sharing a Valkey instance), use distinct application names so they don't fight over the key ring. ## What happens without it Without Valkey-backed persistence (e.g. you forgot to set `CachingOptions:Redis`), each instance writes its key ring to its local filesystem inside the container. Symptoms: - **First request after deploy on a different pod**: user gets logged out / asked to re-confirm email / token-expired errors. - **Email confirmation links from one deploy stop working** after the next deploy if the new pod doesn't share the keys. - **Antiforgery validation fails** intermittently across pods. The fix is simple — set `CachingOptions:Redis`. There's no "production-grade-without-Valkey" alternative; if Valkey isn't available, use Postgres for key persistence via `PersistKeysToDbContext(...)` (the kit doesn't ship this by default but it's a 10-line change). ## Key rotation Data Protection rotates keys automatically every 90 days by default. The active key is used for new protections; expired keys are kept for decrypting older payloads up to a retention period. The key ring grows over time — Valkey storage is negligible (a few KB). Explicit rotation isn't necessary in normal operation; the framework handles it. For incident response (a key may have leaked), revoke the entire ring and force a deploy: ```csharp // One-off recovery path var keyManager = services.GetRequiredService(); foreach (var key in keyManager.GetAllKeys()) keyManager.RevokeKey(key.KeyId, "Suspected leak; cycling."); ``` Everything currently encrypted with the revoked keys becomes unreadable — users have to re-confirm emails, request new password resets, etc. ## Secrets in configuration Not the same thing as Data Protection, but tightly related. The kit's config-borne secrets — `JwtOptions:SigningKey`, `MailOptions:SMTP:Password`, `MailOptions:SendGrid:ApiKey`, the S3 storage secret key, `HangfireOptions:Password`, `CachingOptions:Redis` (if it contains a password) — must come from a secrets manager, not from `appsettings.json` checked into git. Local development uses `dotnet user-secrets`: ```bash dotnet user-secrets set "JwtOptions:SigningKey" "$(openssl rand -base64 32)" --project src/Host/FSH.Starter.Api ``` Production uses environment variables (double underscore for nesting): ```bash JWTOPTIONS__SIGNINGKEY=... MAILOPTIONS__SENDGRID__APIKEY=... HANGFIREOPTIONS__PASSWORD=... ``` …or your cloud secrets manager (Azure Key Vault provider, AWS Systems Manager Parameter Store, etc.) integrated through `Microsoft.Extensions.Configuration`. ## Gotchas - **The shared multiplexer crash story.** When Valkey goes down, Data Protection key reads fail for the duration of the outage. Cookie auth keeps working because keys are cached in-memory after the first read; new encryptions can't happen. Plan your Valkey HA accordingly (Sentinel, Cluster, or a managed Redis-compatible service with multi-AZ). - **Different application names mean different key rings.** Two hosts that share Valkey but use different `SetApplicationName` values can't decrypt each other's payloads. That's usually what you want for multi-env isolation; it's the wrong thing if you accidentally set different names on instances of the same logical app. - **Don't change application name once you've started shipping.** The keys encrypted under the old name become unreadable. If you need to rename, plan a deprecation window where you run both names side-by-side. ## Related - [Caching](/docs/cross-cutting-concerns/caching/) — the shared multiplexer used here. - [Authentication](/docs/security/authentication/) — JWT tokens (which the kit uses by default) are signed, not Data-Protection-encrypted — but email tokens are. - [Production checklist](/docs/security/production-checklist/) — Data Protection wiring is item #9. --- # Operator impersonation > Time-bound, cross-tenant impersonation with a server-side revocation check on every request, full audit trail, and an IGlobalEntity persistence model. Source: https://fullstackhero.net/docs/security/impersonation/ Operator impersonation lets a platform operator temporarily act as another user to debug a problem they're seeing, validate a fix, or recover an account. The kit ships a complete impersonation flow: time-bound grant, cross-tenant support via `IGlobalEntity`, a server-side revocation check on every request, and an audit trail of every start / end / revoke event with full context. Impersonation is the most powerful action in the kit. Every grant is audited (actor, target, tenant, reason, IP, user agent), every issued token's `jti` is checked against the grant table on every request, and grants are time-bound (capped at 60 minutes). Treat the audit trail as a compliance artefact — don't disable it, don't shorten the retention. ## The flow ``` 1. Operator: POST /api/v1/identity/impersonation/start Body: { targetUserId, targetTenantId, reason, durationMinutes? } ↓ 2. Identity module: - Checks the Permissions.Users.Impersonate permission - Cross-tenant targets allowed only for root-tenant actors; tenant admins can impersonate within their own tenant - Rejects self-impersonation and nested impersonation - Builds the target user's claims, mints an access-only token with a fresh jti + act_sub / act_tenant actor claims - Persists an ImpersonationGrant row (IGlobalEntity) keyed by that jti - Audits ImpersonationStarted with full context ↓ 3. Operator uses the impersonation token like any other access token - On every request, the JWT validation hook sees act_sub and checks the grant by jti (HybridCache-primed — no DB hit on the hot path) - If the grant has been ended / revoked / expired, the request fails 401 ↓ 4. Operator: POST /api/v1/identity/impersonation/end → grant marked ended; returns a fresh access + refresh pair for the ORIGINAL actor (seamless swap back) (or the token expires; or another operator revokes via POST /impersonation/grants/{id}/revoke) ``` ## What ships - **`ImpersonationGrant` aggregate** in `Modules.Identity/Domain/ImpersonationGrant.cs` — implements `IGlobalEntity`, opting out of the tenant query filter because a cross-tenant grant doesn't belong to a single tenant. Tenant access is re-restricted explicitly in the query layer via `ActorTenantId` / `ImpersonatedTenantId` filters. - **Time-bound grants** — caller may pass `durationMinutes` (clamped server-side to 1–60); omitted, the token gets the standard `JwtOptions:AccessTokenMinutes` lifetime. - **Server-side revocation** — the JWT bearer `OnTokenValidated` hook looks up the grant by `jti` for any token carrying the `act_sub` claim; ended/revoked grants reject the token immediately. Normal (non-impersonation) tokens skip the check entirely — zero cost. - **Cross-tenant** — actor's tenant and impersonated user's tenant can differ (root-tenant actors only); both are recorded. - **Audit context** — IP address, user agent, client id, reason all captured. - **No refresh token** — impersonation tokens are access-only. When they expire, the operator starts a new grant. ## The grant record ```csharp public class ImpersonationGrant : IGlobalEntity { public Guid Id { get; private set; } public string Jti { get; private set; } // JWT id; revocation key public string ActorUserId { get; private set; } public string? ActorUserName { get; private set; } public string ActorTenantId { get; private set; } public string ImpersonatedUserId { get; private set; } public string? ImpersonatedUserName { get; private set; } public string ImpersonatedTenantId { get; private set; } public string Reason { get; private set; } public DateTime StartedAtUtc { get; private set; } public DateTime ExpiresAtUtc { get; private set; } public DateTime? EndedAtUtc { get; private set; } // operator clicked End public DateTime? RevokedAtUtc { get; private set; } // explicit revoke (kill switch) public string? RevokedByUserId { get; private set; } public string? RevokedByUserName { get; private set; } public string? RevokeReason { get; private set; } public string? ClientId { get; private set; } public string? IpAddress { get; private set; } public string? UserAgent { get; private set; } public bool IsTerminal => EndedAtUtc.HasValue || RevokedAtUtc.HasValue; public bool IsRevoked => RevokedAtUtc.HasValue; } ``` A grant in any terminal state rejects all of its tokens on the next request; natural expiry is handled by the JWT's own lifetime validation. ## Endpoints All under `/api/v1/identity`: | Verb | Route | Gate | What | |---|---|---|---| | POST | `/impersonation/start` | `Permissions.Users.Impersonate` | Start a grant; returns the impersonation access token | | POST | `/impersonation/end` | authenticated impersonation session | End the grant; returns fresh tokens for the original actor | | GET | `/impersonation/grants` | `Permissions.Impersonation.View` | List grants (paginated, filterable) | | POST | `/impersonation/grants/{id}/revoke` | `Permissions.Impersonation.Revoke` | Revoke a grant by id (kill switch) | `end` is deliberately gated only by authentication: the caller holds the **impersonated user's** permissions, which may not include any admin permission — but anyone holding an impersonation token must always be able to end it. ## Cross-tenant mechanics An operator whose JWT carries `tenant=root` can impersonate a user in any tenant; tenant-scoped admins can only impersonate within their own tenant. Two cooperating pieces make the cross-tenant case work: 1. **The grant is `IGlobalEntity`**, so it can be created and queried regardless of the caller's current tenant context. 2. **Claims are built for the target tenant** — the impersonation token's `tenant` claim is the *impersonated* user's tenant, so every downstream tenant-scoped query runs in the target tenant's context, while `act_sub` / `act_tenant` (RFC 8693-style actor claims) preserve who is really acting. ## Audit trail | Action | When | |---|---| | `ImpersonationStarted` | `start` succeeds | | `ImpersonationEnded` | `end` succeeds, or an operator revokes the grant (the revoke writes `ImpersonationEnded` with the revoke context) | Each row carries the actor user / tenant, the impersonated user / tenant, the IP, user agent, client id, and the reason. Natural token expiry doesn't write an audit row — the start row's `ExpiresAtUtc` already bounds the session. Query `/api/v1/audits/security` (filterable by action) for the full timeline. ## Revocation mid-session Revoking a grant takes effect on the impersonator's **next request** — the token-validation hook fails it with 401. The tenant dashboard handles this gracefully: a 401 while the installed token carries the `act_sub` claim routes to a full-screen "Impersonation ended" page that clears the dead token and offers the way back to sign-in, instead of stranding the operator on a half-loaded screen. ## What impersonation isn't - **It's not "log in as another user".** The impersonation token is distinct from the impersonated user's own tokens. The original user can still log in independently with their password, and their sessions are untouched. - **It doesn't bypass permissions.** The impersonation token carries the **impersonated user's** identity — permission checks resolve the impersonated user's roles, not the actor's. If the impersonated user can't access an endpoint, neither can the impersonating operator. - **It doesn't extend across token refresh.** Impersonation tokens are issued without a refresh token. When they expire, the operator starts a new grant. - **It doesn't nest.** Starting an impersonation from an impersonation session is rejected — end the current one first. ## Operational tips - **Keep durations short.** The server caps at 60 minutes; most debug sessions need far less. Shorter grants mean a forgotten-to-end session lingers less. - **Require a `reason`** on every start. Mandatory free text means there's a paper trail of why each impersonation happened. - **Alert on impersonation events.** A single operator impersonating dozens of users in an hour is either a real incident response or a security incident. Either way you want to know. - **Train your team.** "Don't impersonate just to look around" is a real cultural norm. Impersonation is an investigation tool, not a casual UX-debugging shortcut. ## Related - [Identity module](/docs/modules/identity/) — the full implementation. - [Auditing module](/docs/modules/auditing/) — security event capture. - [Architecture: multitenancy deep-dive](/docs/architecture/multitenancy-deep-dive/) — cross-tenant resolution + `IGlobalEntity`. - [Authentication](/docs/security/authentication/) — the JWT pipeline impersonation tokens flow through. --- # Production security checklist > Ten configuration items you must check before shipping fullstackhero to production. Skip none. Source: https://fullstackhero.net/docs/security/production-checklist/ The kit ships with secure defaults wherever possible. Some things, by their nature, you must configure for your environment — there's no "secure default" for a JWT signing key or an allowed-origins list. This page is the ten-item checklist; skip none of them. Print it. Stick it on the wall. Run through every item before flipping DNS to production traffic. None of them take more than 5 minutes; missing any one of them is the kind of incident that ends up in post-mortems. ## 1. Set `JwtOptions:SigningKey` from a secrets manager The repo's dev-only key lives in `appsettings.Development.json`; base and production config leave the key **empty**, and startup validation refuses to boot with an empty key, a key shorter than 32 characters, or the sample `replace-with-…` placeholder. So the failure mode isn't "silently forgeable tokens" — it's "won't start until you set one". Set a real one: ```bash openssl rand -base64 32 ``` Inject via secrets manager / environment variable: ```bash JWTOPTIONS__SIGNINGKEY=... ``` Never check the production key into git, and never reuse the dev key. See [data protection](/docs/security/data-protection/) for the secrets-management patterns. ## 2. Configure password policy to your compliance needs The kit defaults: - 10-character minimum with digit + uppercase + lowercase required (`IdentityOptions` in `IdentityModule`) - 5-password history (prevent reuse) - 90-day expiry with 14-day warning Adjust for your industry. Healthcare (HIPAA) and finance (PCI-DSS) tend to require longer histories and shorter expiries. Consumer products can be looser (longer minimum + no expiry, with strong 2FA). ```jsonc { "PasswordPolicy": { "PasswordHistoryCount": 12, "PasswordExpiryDays": 60, "PasswordExpiryWarningDays": 7, "EnforcePasswordExpiry": true } } ``` ## 3. Allowlist CORS origins `CorsOptions:AllowAll = true` (and the `SetIsOriginAllowed(_ => true)` policy it enables) is **dev only**. Production needs the explicit lists — and note that `appsettings.Production.json` ships `AllowedOrigins` empty, which means **no CORS middleware mounts at all** until you fill it in; your front-ends on other origins will be blocked by the browser. See [CORS & security headers](/docs/security/cors-and-headers/). ```jsonc { "CorsOptions": { "AllowAll": false, "AllowedOrigins": [ "https://app.example.com", "https://admin.example.com" ], "AllowedHeaders": [ "content-type", "authorization" ], "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ] } } ``` ## 4. Review the security headers The kit emits CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and HSTS (on HTTPS responses) by default — verify they survive your reverse proxy, and tune the knobs: ```jsonc { "SecurityHeadersOptions": { "Enabled": true, "ExcludedPaths": [ "/scalar", "/openapi" ], "AllowInlineStyles": true, "ScriptSources": [], // add your analytics/widget origins here "StyleSources": [] } } ``` If your production posture disables the API docs UI (item 7), you can drop the `ExcludedPaths` entries too. Test with browser dev tools open — CSP violations log to the console. ## 5. Turn rate limiting on and tune the values `RateLimitingOptions:Enabled` is `false` in the base config (so local dev never trips it) and `true` in `appsettings.Production.json` — **verify your deployed config actually enables it**. The `auth` policy defaults to **10 requests per minute** per user-or-IP; the global chained limiters cover tenant (1000/min), user (200/min), and IP (300/min): ```jsonc { "RateLimitingOptions": { "Enabled": true, "Auth": { "PermitLimit": 10, "WindowSeconds": 60, "QueueLimit": 0 }, "Tenant": { "PermitLimit": 1000, "WindowSeconds": 60, "QueueLimit": 0 }, "User": { "PermitLimit": 200, "WindowSeconds": 60, "QueueLimit": 0 }, "Ip": { "PermitLimit": 300, "WindowSeconds": 60, "QueueLimit": 0 } } } ``` Adjust `Auth` to your traffic profile: relax for high-volume consumer apps on shared NAT, tighten for internal admin surfaces. ## 6. HTTPS everywhere `UseHttpsRedirection` is on by default. Verify: - Reverse proxy / load balancer terminates TLS with a valid certificate. - `X-Forwarded-Proto: https` forwards to the kit so the redirect middleware doesn't double-redirect — and so the HSTS header (emitted only on HTTPS requests) actually fires. - HTTP/2 or HTTP/3 enabled at the LB for performance. If you're behind Cloudflare / a CDN, also enable "Always Use HTTPS" + "HSTS" at the CDN. ## 7. Lock down or remove debug endpoints In production, decide: - **`/scalar`** (the OpenAPI browser) and **`/openapi/v1.json`** — `appsettings.Production.json` ships with `OpenApiOptions:Enabled = false`, which removes both. If you re-enable them, consider whether revealing your API surface to the world is what you want. Note these paths also bypass the security-headers middleware and authorization (so the docs UI works) — another reason to keep them off in production. - **`/health`** — expose to your platform's probes; firewall externally. - **OpenTelemetry collector endpoints** — these are inbound to your collector, not the kit, but verify they're not Internet-exposed. ## 8. Lock down the Hangfire dashboard The jobs dashboard (default route **`/jobs`**, configurable via `HangfireOptions:Route`) is gated by basic auth. Startup validation enforces a username of 3+ chars and a password of 12+ chars — empty values won't boot. Verify: - Username is **not** `admin` (rename to something less guessable). - Password is **long** (16+ random chars) and **not** the dev value from `appsettings.Development.json`. - Behind HTTPS (basic auth is plaintext otherwise). - IP-allowlist at the reverse proxy (Cloudflare WAF rule, Nginx `allow`/`deny`, etc.) — only your ops IPs. ```jsonc { "HangfireOptions": { "UserName": "ops-team", "Password": "set-via-secrets-manager", "Route": "/jobs" } } ``` ## 9. Configure Data Protection key persistence Set `CachingOptions:Redis`. Without it, each instance maintains its own Data Protection key ring locally, and email-confirmation links, password reset links, and webhook signing secrets stop working across rolling deploys. See [data protection](/docs/security/data-protection/). ## 10. Enable audit retention The kit's `AuditRetentionJob` is **opt-in** (`Enabled` defaults to false). Without it, the audit table grows forever. Configure: ```jsonc { "Auditing": { "Retention": { "Enabled": true, "ActivityRetentionDays": 30, "EntityChangeRetentionDays": 90, "SecurityRetentionDays": 365, "ExceptionRetentionDays": 180, "DeleteBatchSize": 5000, "Cron": "30 3 * * *" } } } ``` Adjust retention windows per your compliance regime. Most teams want **security events kept much longer** than activity events (a year vs a month is typical). ## Bonus: items worth doing within the first month These aren't blockers, but the sooner the better: - **Make 2FA mandatory for admin roles.** A leaked admin password compromises everything; require TOTP. - **Add monitoring + alerts** on auth-failure spikes, impersonation events, and 5xx error rates. - **Run a vulnerability scan** of the deployed image (Snyk, Trivy, Dependabot) to catch transitive CVEs early. - **Set up backup + restore tests** for Postgres + MinIO. Backups you haven't tested restoring are not backups. - **Document an incident-response runbook** — who's on-call, where the alerts go, how to revoke every session (admin revoke-all is built in), how to rotate the JWT signing key in an emergency. ## Related - [Authentication](/docs/security/authentication/), [Authorization](/docs/security/authorization/), [Impersonation](/docs/security/impersonation/), [Two-factor](/docs/security/two-factor/) — the kit's auth primitives. - [Webhook signing](/docs/security/webhook-signing/) — HMAC integrity. - [Data protection](/docs/security/data-protection/) — key persistence. - [CORS & headers](/docs/security/cors-and-headers/) — the network-layer hardening. --- # Two-factor authentication (TOTP) > Opt-in TOTP enrolment for any user — shared secret + otpauth URI, verify, password-confirmed disable, with TOTP-required token issuance once enrolled. Source: https://fullstackhero.net/docs/security/two-factor/ Two-factor TOTP is opt-in per user in fullstackhero. Once enrolled, the user must provide a six-digit TOTP code from their authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.) on every login. Three endpoints handle the lifecycle: enrol (returns the shared secret + otpauth URI), verify (activates the enrolment), disable (turns it off, after confirming the password). The kit doesn't force 2FA. A reasonable production posture is to make TOTP **mandatory** for admin roles (Admin, billing-admin, anyone with destructive permissions) and **opt-in** for end users. Wire the "require 2FA" check into the role check during login. ## The flow ``` 1. User: POST /api/v1/identity/2fa/enroll ↓ 2. Identity module: - Generates (or rotates) the user's authenticator shared secret - Builds the otpauth:// URI (issuer "FullStackHero") - Returns { sharedKey, authenticatorUri } ↓ 3. Client renders authenticatorUri as a QR code; user scans it, app starts generating 6-digit codes ↓ 4. User: POST /api/v1/identity/2fa/verify { code } - If the code matches the current TOTP window, 2FA is activated - User.TwoFactorEnabled = true persists ↓ 5. From now on, POST /token/issue requires { email, password, twoFactorCode } (the third field is required iff enrolled) - Missing code → 401 "two_factor_required" - Wrong code → 401 "two_factor_invalid" ↓ 6. To turn off: POST /api/v1/identity/2fa/disable { currentPassword } - Requires the current password, so a stolen access token alone can't downgrade account security; also rotates the secret ``` ## Endpoints All under `/api/v1/identity`, all requiring an authenticated caller — TOTP is a per-user setting. | Verb | Route | What | |---|---|---| | POST | `/2fa/enroll` | Generate (or rotate) the secret; return `sharedKey` + `authenticatorUri` for the client to render as a QR | | POST | `/2fa/verify` | Verify the first TOTP code; activate 2FA | | POST | `/2fa/disable` | Disable 2FA after confirming the current password; rotates the secret | Calling enroll again before verifying **rotates the secret** — a stale code from a prior incomplete enrolment can't silently succeed. The verify endpoint strips spaces from the code, so "123 456" works. ## What the kit does NOT ship - **SMS / email 2FA**. Only TOTP. SMS is widely-acknowledged-insecure (SIM-swap attacks); email mostly defeats the second-factor purpose since it shares the same compromised channel as password recovery. - **Backup / recovery codes**. The kit doesn't generate one-time backup codes. If a user loses their authenticator device, support has to reset 2FA manually (disable, then re-enrol). For production with non-technical users, this is the biggest gap to fill — implement 8-10 single-use recovery codes generated at enrolment time, hashed-and-stored, displayed to the user once. - **WebAuthn / passkeys.** No passkey support in v10. Roadmap. ## TOTP integration The kit uses ASP.NET Identity's built-in authenticator support — the shared secret is managed by `UserManager` token providers; you never write to it directly. ```csharp // EnrollTwoFactorCommandHandler (simplified) public async ValueTask Handle(EnrollTwoFactorCommand command, CancellationToken ct) { var user = await _userManager.FindByIdAsync(_currentUser.GetUserId().ToString()) ?? throw new NotFoundException("user not found"); // Always reset so calling enroll twice rotates the secret await _userManager.ResetAuthenticatorKeyAsync(user); var sharedKey = await _userManager.GetAuthenticatorKeyAsync(user); var authenticatorUri = $"otpauth://totp/FullStackHero:{user.Email}" + $"?secret={sharedKey}&issuer=FullStackHero&digits=6"; return new TwoFactorEnrollmentResponse(sharedKey, authenticatorUri); } ``` The `otpauth://` URI is the RFC 6238 standard format that every authenticator app understands. The client (not the server) renders it as a QR code; the raw `sharedKey` is returned too, for users who can't scan. ## Validating during login During `token/issue`, after the password check passes: ```csharp // IdentityService (simplified) if (user.TwoFactorEnabled) { if (string.IsNullOrWhiteSpace(twoFactorCode)) throw new CustomException("two_factor_required: ...", null, HttpStatusCode.Unauthorized); var valid = await _userManager.VerifyTwoFactorTokenAsync( user, _userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode); if (!valid) throw new UnauthorizedException("two_factor_invalid: ..."); } ``` The error prefixes (`two_factor_required` / `two_factor_invalid`) are stable strings the React clients key off to show the code-entry step. The whole login — including 2FA attempts — sits behind the rate-limit `auth` policy, which is the throttle on TOTP brute-forcing. Failed TOTP codes do **not** increment the password-lockout counter; only failed passwords do. ## Operational considerations - **Time skew.** TOTP codes are valid for a small window (typically 30 seconds + 1 step on either side). Servers and users' devices must have accurate clocks. NTP everywhere. - **Device loss.** Without backup codes, a lost device means admin intervention. Build a tested support runbook before turning 2FA on for non-technical users. - **Recovery codes (recommended add-on).** Add backup-code generation to the enrolment flow and store them hashed. Pattern: ``` 1. At enrol: generate 10 random alphanumeric codes 2. Hash them via Identity password-hasher 3. Display the plaintext codes ONCE to the user 4. On login, accept a code in place of the TOTP code; mark the used code ``` This isn't shipping in v10 but it's a ~200 LoC addition you'd add in your fork. - **Watch disable events.** A spike of 2FA disables on one account is a red flag (account takeover trying to remove the second factor). The disable endpoint requires the password precisely to raise that bar — alert on it anyway. ## Related - [Authentication](/docs/security/authentication/) — the login flow 2FA stacks onto. - [Identity module](/docs/modules/identity/) — full endpoint inventory. - [Production checklist](/docs/security/production-checklist/) — when to make 2FA mandatory. --- # Webhook signing > HMAC-SHA256 payload signing for every outbound webhook delivery, secrets encrypted at rest via Data Protection, and a complete subscriber-side verification recipe. Source: https://fullstackhero.net/docs/security/webhook-signing/ Every outbound webhook delivery from the kit can be signed with HMAC-SHA256. Subscribers verify the signature against the same shared secret to confirm the payload came from you and wasn't tampered with in transit. Each attempt carries a fresh `X-Webhook-Delivery-Id` so receivers can dedupe retries. Subscriptions created without a `secret` are delivered **unsigned** — the signature header is simply omitted. If your tenants' systems should be able to verify deliveries, force the secret to be non-empty in your `CreateWebhookSubscriptionCommand` validator. ## Secrets are encrypted at rest The signing secret is the HMAC key, so it must be recoverable — a one-way hash would make signing impossible. The kit therefore encrypts it with ASP.NET Data Protection (`WebhookSecretProtector`, purpose string `FSH.Webhooks.SubscriptionSecret.v1`) when the subscription is created, and decrypts it only at dispatch time: ```csharp // CreateWebhookSubscriptionCommandHandler var protectedSecret = secretProtector.Protect(command.Secret); var subscription = WebhookSubscription.Create(command.Url, command.Events, protectedSecret); ``` A database leak alone doesn't expose signing secrets — the attacker would also need your [Data Protection key ring](/docs/security/data-protection/). This is also why multi-instance hosts must share the key ring (Valkey-backed persistence): a secret encrypted on one node must decrypt on another. ## Headers on every delivery | Header | Value | Purpose | |---|---|---| | `Content-Type` | `application/json` | Standard JSON content | | `X-Webhook-Event` | The event type name | Routes the payload to the right subscriber handler | | `X-Webhook-Delivery-Id` | A fresh UUID per attempt | Receiver-side idempotency / dedup key | | `X-Webhook-Signature` | `sha256={hex}` | HMAC-SHA256 of the raw request body using the subscription's secret | The signature covers the **body only**. Since retries re-send the same body, the signature is identical across attempts; the delivery id is what changes per attempt (each attempt is its own `WebhookDelivery` row in the delivery log). ## How the kit signs ```csharp // src/Modules/Webhooks/Modules.Webhooks/Services/WebhookPayloadSigner.cs public static class WebhookPayloadSigner { public static string Sign(string payload, string secret) { var keyBytes = Encoding.UTF8.GetBytes(secret); var payloadBytes = Encoding.UTF8.GetBytes(payload); var hash = HMACSHA256.HashData(keyBytes, payloadBytes); return $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}"; } } ``` The dispatch job decrypts the stored secret and sets the header: ```csharp // WebhookDispatchJob (simplified) var signingSecret = _secretProtector.Unprotect(subscription.SecretHash); if (!string.IsNullOrEmpty(signingSecret)) { content.Headers.Add("X-Webhook-Signature", WebhookPayloadSigner.Sign(payloadJson, signingSecret)); } ``` The header value is always `sha256=` + lowercase hex digest. No surrounding whitespace, no Base64 alternative — one format, stick to it. ## Subscriber-side verification (.NET) The receiver computes the same HMAC, then compares using a timing-safe equality check: ```csharp using System; using System.Security.Cryptography; using System.Text; public static bool VerifyWebhook(string body, string signatureHeader, string secret) { if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256=", StringComparison.Ordinal)) return false; var expected = signatureHeader["sha256=".Length..]; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); var actual = Convert.ToHexStringLower(hash); // Timing-safe equality — critical to prevent timing-side-channel leaks return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual)); } ``` The `FixedTimeEquals` is what protects against an attacker measuring response times to guess the signature byte-by-byte. Don't use a plain string `==` for signature comparison. ## Subscriber-side verification (Node / Express) ```js import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; const app = express(); // Receive the raw body so we can re-hash it. app.post('/webhooks/fullstackhero', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.get('X-Webhook-Signature') ?? ''; if (!signature.startsWith('sha256=')) return res.status(401).end(); const expected = signature.slice('sha256='.length); const actual = createHmac('sha256', process.env.WEBHOOK_SECRET) .update(req.body) .digest('hex'); if (!timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(actual, 'utf8'))) return res.status(401).end(); // … process payload res.status(200).end(); }); ``` Critical detail: hash the **raw bytes** of the request body, not the parsed JSON object. Re-serialising the parsed object reorders fields and changes the digest. ## Dedup and replay `X-Webhook-Delivery-Id` is a fresh UUID per attempt. Receivers should: 1. Track the most recent N delivery ids per subscription (in Redis / a small DB table). 2. Treat any delivery whose id is already in the table as already-processed (idempotency). 3. Expire entries older than ~24 hours. That handles **retries and network-layer duplicates** cleanly — the kit retries failed deliveries up to 4 times (30s / 2m / 10m / 1h backoff), and each attempt's id is distinct. Be clear about what it does *not* handle: the delivery id is a header, **outside the signed body**, so id-dedup is not a defence against a deliberate attacker replaying a captured payload with a fresh id. For real replay defence, include a timestamp inside the signed payload and reject anything older than N minutes. The kit doesn't ship that by default — for most integrations, TLS plus signature verification plus id-dedup is the right cost/benefit. ## What signing doesn't protect against - **Compromised secrets.** If the shared secret leaks, anyone can sign payloads. Rotate by deleting the subscription and recreating it with a fresh secret (there's no in-place secret update); communicate the rotation to tenants out-of-band. - **A malicious tenant attacking themselves.** The signing protects the channel between you and the subscriber. It says nothing about whether the subscriber's own systems are trustworthy. - **TLS downgrade.** Always use HTTPS endpoints in subscriptions (the URL validator accepts http and https — enforce https in production). The signature doesn't substitute for transport encryption — anyone watching the wire can still read the payload, even if they can't tamper with it. ## Operational tips - **Log every signature failure** on your receiving side. A pattern of failures means either the subscriber's secret is out of sync or someone's probing. - **Don't return rich error messages on signature mismatch.** A simple 401 — no "expected X, got Y" — denies an attacker the oracle they'd need to reverse-engineer the signing scheme. - **Make secrets long.** 32+ random bytes (`openssl rand -hex 32`). Short secrets are guessable through offline brute force against captured signed payloads. ## Related - [Webhooks module](/docs/modules/webhooks/) — the dispatcher + subscription lifecycle. - [Data protection](/docs/security/data-protection/) — the key ring that encrypts subscription secrets at rest. - [HTTP resilience](/docs/cross-cutting-concerns/http-resilience/) — caller-side retry policy. --- # Overview > Three layers of tests — unit (xUnit + Shouldly + NSubstitute + AutoFixture), integration (Testcontainers), and architecture (NetArchTest) — around 1,500 tests green on every build. Source: https://fullstackhero.net/docs/testing/ fullstackhero ships with around **1,500 `[Fact]` + `[Theory]` tests** that run green on every build. Three layers: unit tests verify domain invariants and handler logic without infrastructure; integration tests spin up real Postgres + MinIO via Testcontainers and exercise the API end-to-end through `Microsoft.AspNetCore.Mvc.Testing`; architecture tests enforce module boundaries, contracts purity, and naming conventions as compiler-level rules. Every structural rule in the kit — "modules never reference each other's runtime", "every command handler has a validator", "contracts assemblies stay dependency-pure" — is an assertion in `src/Tests/Architecture.Tests/`. Break the rule, fail the build. Behavioural guarantees like permission enforcement are covered end-to-end in the integration suite instead. ## The pages ## Related - [Architecture: modular monolith](/docs/architecture/modular-monolith/) — the rules architecture tests enforce. - [Architecture: vertical slice](/docs/architecture/vertical-slice/) — the slice shape unit tests mirror. - [Catalog module](/docs/modules/catalog/) — the most thoroughly tested module; use its integration tests as templates. --- # Architecture tests > Reflection + NetArchTest assertions that enforce module boundaries, contracts purity, naming conventions, and architectural invariants as compile-time rules. Source: https://fullstackhero.net/docs/testing/architecture-tests/ The architecture suite checks structural rules at build time. The kit ships **around 50 tests across 14 rule files** in `src/Tests/Architecture.Tests/` that enforce module boundaries, contracts purity, layering, naming conventions, and the domain conventions vertical slices rely on. Break a rule, fail the build. The suite mixes NetArchTest's fluent API with plain reflection and even `.csproj` parsing — whatever expresses the rule most directly. Every architectural decision is also an assertion in `src/Tests/Architecture.Tests/`. Want to know whether modules can reference each other's runtime? Don't ask the architect; read `ModuleArchitectureTests.cs`. The rules and the documentation can't drift apart because the rules are the documentation. ## The rule files | Test file | Enforces | |---|---| | `ModuleArchitectureTests` | Module runtime projects never reference another module's runtime project (parses `.csproj` references — only `.Contracts` is allowed) | | `ContractsPurityTests` | `*.Contracts` assemblies don't depend on EF Core, FluentValidation, Hangfire, or module implementations; no DbContext/repository types; commands and queries are records or sealed | | `BuildingBlocksIndependenceTests` | Building blocks don't depend on modules or hosts; Core is dependency-free; the blocks layer correctly among themselves | | `CircularReferenceTests` | No circular project references — across the solution, the modules, or the building blocks | | `LayerDependencyTests` | Core doesn't depend on EF/ASP.NET; domain types don't reach into persistence or infrastructure | | `HandlerValidatorPairingTests` | Every command handler — and every paginated query handler — has a matching validator | | `DomainEntityTests` | Domain events implement `IDomainEvent` and are sealed; entities implement `IEntity`; aggregate roots don't reference other aggregates directly; value objects are immutable | | `EndpointConventionTests` | Endpoint classes are static, live under `Features`, expose a `Map…` method that takes `IEndpointRouteBuilder` and returns `RouteHandlerBuilder`, contain no business logic, and follow the naming convention | | `ApiVersioningTests` | Every feature lives under a versioned namespace (`Features.v{N}`); `v1` never depends on higher versions; commands/queries sit in the same version as their handlers | | `FeatureArchitectureTests` | Feature versions never depend on newer versions | | `HostArchitectureTests` | Modules don't depend on hosts; hosts don't reach into module internals | | `NamespaceConventionsTests` | Building-block namespaces match folder structure | | `TenantIsolationTests` | Every entity in a `BaseDbContext` is tenant-isolated or explicitly marked `IGlobalEntity` | | `ModuleAssemblyDiscovery` | Guard: the discovery helper found at least one module assembly (prevents the whole suite silently no-opping) | ## How modules are discovered Most rules iterate `ModuleAssemblyDiscovery.GetModuleAssemblies()`, which scans the test output directory for `FSH.Modules.*.dll` (excluding `.Contracts`). Adding a new module to the suite only requires adding its project reference to `Architecture.Tests.csproj` — no rule file changes. A guard test fails if discovery ever comes back empty. ## An example rule The module-boundary rule doesn't even need reflection — it reads each runtime module's `.csproj` and asserts no `ProjectReference` points at another module's runtime project: ```csharp // src/Tests/Architecture.Tests/ModuleArchitectureTests.cs [Fact] public void Modules_Should_Not_Depend_On_Other_Modules() { var runtimeProjects = Directory .GetFiles(modulesRoot, "Modules.*.csproj", SearchOption.AllDirectories) .Where(path => !path.Contains(".Contracts", StringComparison.OrdinalIgnoreCase)); foreach (string projectPath in runtimeProjects) { var references = XDocument.Load(projectPath) .Descendants("ProjectReference") .Select(x => (string?)x.Attribute("Include") ?? string.Empty); // any reference to Modules.* that is not *.Contracts fails the test // with the offending project + reference named in the message } } ``` One Fact, every module under test, and the failure message names the offending project so you know exactly where to look. ## Handler / validator pairing The rule the kit leans on most: every command handler must have a matching validator, and every query handler whose query has `PageNumber`/`PageSize` properties must too. The test reflects over `ICommandHandler<>`/`IQueryHandler<,>` implementors in every module assembly and looks for an `AbstractValidator`. Deliberate exceptions go in explicit allowlists at the top of the file (`KnownMissingCommandHandlers`, `KnownMissingQueryHandlers`) — so an exemption is a visible, reviewable code change rather than a silent gap. ## Endpoint conventions Minimal-API endpoints in the kit are static classes under a `Features` namespace, exposing a `Map…` extension method on `IEndpointRouteBuilder` that returns `RouteHandlerBuilder`. `EndpointConventionTests` enforces all of it — shape, namespace, signature, naming, and a "no business logic in endpoints" rule. That's what keeps 165+ endpoint classes consistent; new endpoints that drift get caught by CI. ## Feature folder convention `ApiVersioningTests.Feature_Folders_Should_Follow_Version_Convention` requires every type under `Features` to live under `Features.v{N}`. This is why the kit has `Features/v1/Users/RegisterUser/` and not `Features/Users/RegisterUser/`. The version segment is mandatory; older versions stick around as `v1/` while you build `v2/` — and a companion rule guarantees `v1` never depends on `v2`. ## Running the architecture suite ```bash # Run just the architecture tests — fast, no containers dotnet test src/Tests/Architecture.Tests/ ``` There's no infrastructure to spin up — assembly scanning + reflection is all the suite needs, so it runs in seconds. ## Adding a new rule When a new architectural convention emerges (or you've fixed a recurring code-review nit), encode it: ```csharp // src/Tests/Architecture.Tests/MyNewRuleTests.cs namespace Architecture.Tests; public sealed class MyNewRuleTests { /// /// Specification: every Mediator handler must be public sealed. /// Why: source-gen requires concrete types; sealed prevents accidental /// inheritance which would break the generator. /// [Fact] public void Mediator_Handlers_Should_Be_Public_Sealed() { var failures = new List(); foreach (var module in ModuleAssemblyDiscovery.GetModuleAssemblies()) { var handlers = module.GetTypes() .Where(t => t.IsClass && !t.IsAbstract) .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>) || i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)))); failures.AddRange(handlers .Where(h => !h.IsPublic || !h.IsSealed) .Select(h => h.FullName!)); } failures.ShouldBeEmpty($"Handlers must be public sealed: {string.Join(", ", failures)}"); } } ``` Three essentials, all visible in the shipped rules: - **XML doc comment** stating the rule and *why*. The comment is the readable specification; the assertion is the enforcement. Without the why, future contributors won't know whether the rule is still relevant. - **Specific failure message** — name the offending types. - **An explicit allowlist** if there are legitimate exceptions, so exemptions are code-reviewed, not invisible. ## What architecture tests don't enforce - **Runtime behaviour.** A class can satisfy every structural rule and still have a logic bug. Architecture tests are structural; behaviour belongs in unit / integration tests. - **Authorization.** "Every endpoint requires the right permission" is a runtime guarantee — the integration suite's authorization tests cover it end-to-end (401 without a token, 403 without the permission). - **Performance.** A rule that "no handler can be slow" can't be expressed; benchmarks belong in a separate suite (the kit doesn't ship benchmarks; you'd run BenchmarkDotNet against your fork). - **Security.** Architecture tests check "does this code obey the architectural invariants," not "is this code injection-vulnerable." Security testing is a separate effort (SAST tools, penetration tests). ## Why this matters Architectural rules drift slowly. One PR adds a temporary cross-module reference "just for testing"; another developer copies the pattern; a year later, the modular monolith has cyclic dependencies. Architecture tests stop this in the first PR. These rules cost almost nothing — seconds per run, no infrastructure. Use them generously. Every rule you've ever had to enforce in code review is a rule you could encode here. ## Related - [Unit tests](/docs/testing/unit-tests/) — for behaviour. - [Integration tests](/docs/testing/integration-tests/) — for end-to-end round trips. - [Modular monolith](/docs/architecture/modular-monolith/) — the boundaries these tests enforce. - [Vertical slice architecture](/docs/architecture/vertical-slice/) — the slice shape these tests enforce. --- # Fixtures & seed data > The shared test factory (containers, host, seeded root tenant + admin) used across the integration suite, and the service swaps that keep tests deterministic. Source: https://fullstackhero.net/docs/testing/fixtures-and-seed-data/ The single `Integration.Tests` project shares one factory — and therefore one set of containers, one host, and one seeded database — across every test in the suite. Each test runs in milliseconds once the containers are warm. This page is how the sharing works. Everything described here lives in `src/Tests/Integration.Tests/Infrastructure/`. The whole suite shares one database across hundreds of tests. Give everything your test inserts a unique name (a `Guid` suffix does fine) so tests stay independent of each other and of execution order. ## The shared pieces | Piece | Type | Role | |---|---|---| | `FshWebApplicationFactory` | `WebApplicationFactory, IAsyncLifetime` | Owns the `postgres:17-alpine` + `minio/minio:latest` Testcontainers, builds the in-process host, migrates + seeds | | `FshCollectionDefinition` | `ICollectionFixture` | Shares **one** factory instance across the whole suite (`[Collection(FshCollectionDefinition.Name)]`) | | `AuthHelper` | Plain class, wraps the factory | Issues real tokens via `POST /token/issue` and builds authenticated `HttpClient`s | | `TestConstants` | Static class | Root tenant id, root admin credentials, JWT settings, per-module base paths | | `NoOpMailService` | `IMailService` swap | No SMTP, no Hangfire retry noise | | `DetailedTestExceptionHandler` | `IExceptionHandler` swap | Failures show the real exception instead of "An unexpected error occurred" | Because the factory is a *collection* fixture (not a class fixture), the containers start once at the beginning of the test session and dispose at the end. They're created with `WithAutoRemove(true)`, so nothing lingers after the run. ## Connection wiring The factory pulls connection details from its containers during `ConfigureWebHost` and overlays them on the production configuration: ```csharp builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["DatabaseOptions:Provider"] = "POSTGRESQL", ["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(), ["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL", ["CachingOptions:Redis"] = "", // HybridCache in-memory fallback — no cache container ["JwtOptions:SigningKey"] = TestConstants.JwtSigningKey, ["RateLimitingOptions:Enabled"] = "false", ["SecurityHeadersOptions:Enabled"] = "false", ["Storage:Provider"] = "s3", ["Storage:S3:ServiceUrl"] = _minio.GetConnectionString(), // ... bucket, keys, region }); }); ``` `AddHeroStorage` reads `Storage:Provider` **eagerly**, before this overlay applies — so the factory also removes the registered `IStorageService` and re-registers the S3 stack against the MinIO container in `ConfigureServices` (`RewireStorageForS3`). Config-only overrides aren't enough for eagerly-bound services. The factory also swaps Hangfire to `Hangfire.InMemory` (with a real server polling every second, so jobs actually run), replaces mail with `NoOpMailService`, and removes hosted services that would race the test migrations (role-permission sync, outbox dispatcher). ## Seeded data On first initialization the factory provisions the database through the **production code paths**: 1. Migrates the tenant catalog (`TenantDbContext.Database.MigrateAsync()`). 2. Seeds the **root tenant** if missing. 3. Runs every module's `IDbInitializer.MigrateAsync` and `SeedAsync` under the root tenant context — which creates the root admin, roles, permissions, and groups. 4. Runs the `RolePermissionSyncer` so permissions match the live permission catalog. What you can rely on from `TestConstants`: ```csharp TestConstants.RootTenantId // "root" TestConstants.RootAdminEmail // "admin@root.com" TestConstants.DefaultPassword // the seeded admin password TestConstants.CatalogBasePath // "/api/v1/catalog" — and friends per module ``` That's it — **no demo tenants, no seeded brands or products**. Tests that need an `acme`-style tenant, a low-privilege user, or catalog rows create them through the API in their own Arrange step. That keeps every test self-describing and safe to run in parallel. ## Authentication helpers `AuthHelper` doesn't mint JWTs by hand — it logs in through the real identity endpoint, so the token has been through the production issuing path: ```csharp var auth = new AuthHelper(factory); // Root admin, all permissions using var admin = await auth.CreateRootAdminClientAsync(); // Any user your test created using var user = await auth.CreateAuthenticatedClientAsync(email, password, tenant); // Anonymous (for 401 tests) — set the tenant header yourself using var anon = factory.CreateClient(); anon.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); ``` Both authenticated helpers set the `Authorization: Bearer` **and** `tenant` headers for you. ## When tests need their own state Tests should **insert**, not **mutate** shared rows — and insert with unique names: ```csharp [Fact] public async Task MyTest() { using var client = await _auth.CreateRootAdminClientAsync(); // Arrange — unique name avoids parallel-test collisions var name = $"test-brand-{Guid.NewGuid():N}"[..20]; var createResponse = await client.PostAsJsonAsync( $"{TestConstants.CatalogBasePath}/brands", new { name, description = "mine", logoUrl = (string?)null }); // Act / Assert against the row this test owns } ``` Prefer going through the API. When a test must reach into the database directly, create a scope from `factory.Services` — and mind the tenant-context gotcha below. Finbuckle's `IMultiTenantContextSetter` writes an `AsyncLocal`. Set it **in the same method** as the `UserManager`/DbContext call that needs it — a helper that sets it and is then awaited loses the value on return, and the tenant query filter throws. Several tests carry this exact comment (`Tests/Authentication/PasswordResetTests.cs`). ## When to add to the harness - **New shared infrastructure.** If you need RabbitMQ, Elasticsearch, etc., add the container as another field on `FshWebApplicationFactory`, start it in `InitializeAsync`, and overlay its connection details — same shape as the MinIO wiring. For something only one test class needs (like the Valkey container in `HybridCacheRedisTests`), keep it local to that class via `IAsyncLifetime` instead. - **New always-present seed data.** Extend the module's `IDbInitializer` seed — that's the production path, and the factory runs it automatically. - **One-off setup per test class.** Don't touch the harness; arrange it in the test class constructor or the test itself. ## Common mistakes - **Sharing a `DbContext` across tests.** Each scope should be its own — use `factory.Services.CreateScope()` inside the test; don't reach into the factory's root provider. - **Mutating shared rows.** The root admin and root tenant are read-only in spirit; a test that disables the root admin breaks every test after it. - **Forgetting the `tenant` header.** Without it, Finbuckle resolves no tenant and tenant-aware endpoints fail. The `AuthHelper` clients set it for you; raw `factory.CreateClient()` does not. - **`Thread.Sleep(...)` in tests.** "Wait 200 ms for the job to run" is the smell of a missing signal. Find the deterministic completion (a poll with timeout, a hub event, a domain assertion) and assert against that. ## Related - [Integration tests](/docs/testing/integration-tests/) — the test layer that uses this harness. - [Running tests](/docs/testing/running-tests/) — how to invoke the suite locally + in CI. - [Writing new tests](/docs/testing/writing-new-tests/) — recipes for each layer. --- # Integration tests > WebApplicationFactory in-process API tests against Testcontainers Postgres + MinIO — one container set, all modules, real round trips. Source: https://fullstackhero.net/docs/testing/integration-tests/ Integration tests in fullstackhero spin up real infrastructure in containers — PostgreSQL 17 and MinIO — and exercise the API end-to-end through `Microsoft.AspNetCore.Mvc.Testing`'s `WebApplicationFactory`. One factory (and therefore one container set) serves the entire `Integration.Tests` project via an xUnit collection fixture, so the startup cost is paid once and the per-test cost is small. Splitting integration tests into per-module projects would mean per-project container startup. The single project shares one factory + one container set across every test class, runs faster, and you don't have to think about container leakage across boundaries. ## The stack | Layer | Library | Purpose | |---|---|---| | Host | `Microsoft.AspNetCore.Mvc.Testing` | `WebApplicationFactory` for the in-process API | | Containers | `Testcontainers.PostgreSql` / `Testcontainers.Minio` 4.11 | `postgres:17-alpine` + `minio/minio:latest` per test run | | HTTP | `HttpClient` from `factory.CreateClient()` | Real HTTP calls through the full middleware pipeline | | Assertions | Shouldly, NSubstitute, AutoFixture | Same as unit tests | There is **no Redis/Valkey container** in the shared harness — `CachingOptions:Redis` is set to an empty string, so HybridCache runs on its in-memory fallback and Hangfire runs on `Hangfire.InMemory`. The one place a real cache engine matters, `Tests/Caching/HybridCacheRedisTests.cs`, spins up its own `valkey/valkey:9.1.0-alpine` container to guard against in-memory-vs-distributed serialization divergence. ## The fixture pattern `FshWebApplicationFactory` (in `src/Tests/Integration.Tests/Infrastructure/`) extends `WebApplicationFactory` and owns the Postgres + MinIO containers as fields. It is shared across the whole suite through a **collection fixture**: ```csharp // Infrastructure/FshCollectionDefinition.cs [CollectionDefinition(Name)] public sealed class FshCollectionDefinition : ICollectionFixture { public const string Name = "FshIntegration"; } ``` Every test class joins the collection and takes the factory in its constructor: ```csharp [Collection(FshCollectionDefinition.Name)] public sealed class BrandsEndpointTests { private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; public BrandsEndpointTests(FshWebApplicationFactory factory) { _factory = factory; _auth = new AuthHelper(factory); } [Fact] public async Task CreateBrand_Should_Return200_And_Persist_When_AuthorizedAdmin() { using var client = await _auth.CreateRootAdminClientAsync(); var name = UniqueName("CreateOk"); var createResponse = await client.PostAsJsonAsync($"{TestConstants.CatalogBasePath}/brands", new { name, description = "Brand created by integration test", logoUrl = (string?)null, }); createResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var brandId = await createResponse.DeserializeAsync(); var getResponse = await client.GetAsync($"{TestConstants.CatalogBasePath}/brands/{brandId}"); var fetched = await getResponse.DeserializeAsync(); fetched.Name.ShouldBe(name); } } ``` On startup (`IAsyncLifetime.InitializeAsync`) the factory starts both containers in parallel, creates the MinIO bucket, then **migrates the tenant catalog, seeds the root tenant, and runs every module's `IDbInitializer` migrate + seed** — including the production `RolePermissionSyncer`. A semaphore stops test classes from racing the migration. ## Authenticated test clients There are no hand-minted JWTs. `AuthHelper` logs in through the **real token endpoint** (`POST /api/v1/identity/token/issue`) as the seeded root admin (`admin@root.com`), so tokens go through the production auth pipeline: | Helper | Purpose | |---|---| | `CreateRootAdminClientAsync()` | `HttpClient` authenticated as the seeded root admin, `tenant: root` header set | | `CreateAuthenticatedClientAsync(email, password, tenant)` | Same, for any user/tenant your test has created | | `GetTokenAsync(email, password, tenant)` | Just the token, when you need to build the client yourself | | `_factory.CreateClient()` | Anonymous — for 401 tests; remember to add the `tenant` header | Constants like `TestConstants.RootAdminEmail`, `TestConstants.DefaultPassword`, and the per-module base paths (`TestConstants.CatalogBasePath` etc.) live in `Infrastructure/TestConstants.cs`. Tests that need a non-root tenant or a low-privilege user create them through the API — there are no pre-seeded demo tenants in the test database. ## What the factory swaps out The factory overrides configuration and services so tests are deterministic: - **Hangfire** runs on `Hangfire.InMemory` with a real server (1-second polling), so background jobs actually execute. - **Mail** is a `NoOpMailService` — no SMTP, no retries. - **Exceptions** surface through a `DetailedTestExceptionHandler` instead of the generic production error response, so failures tell you what broke. - **Rate limiting and security headers** are disabled (they get their own dedicated suite — see below). - **Storage** is rewired to S3-against-MinIO *after* registration: `AddHeroStorage` reads `Storage:Provider` from configuration **eagerly** — before the test config overlay applies — so it wires the local-disk provider. The factory removes the registered `IStorageService` and re-registers the S3 stack pointed at the MinIO container (`RewireStorageForS3`). If you build your own factory, you need the same trick. ## Two more harness gotchas - **SignalR tests force long polling.** `TestServer` has no WebSocket support, so the chat tests configure `HttpTransportType.LongPolling` on their hub connections. A hub test that "hangs forever" is almost always a missing transport override. - **Set the tenant context inline.** When a test reaches past HTTP into a scoped service (`UserManager`, a DbContext), it must set the Finbuckle tenant context via `IMultiTenantContextSetter` **in the same method** as the call that needs it. The setter writes an `AsyncLocal` — set it inside an awaited helper and the value is gone when the helper returns, and the tenant query filter NREs. ## The middleware suite `Integration.Middleware.Tests` is a separate, smaller project that keeps the **production** middleware wiring: the real `GlobalExceptionHandler` (RFC 9457 responses), rate limiting with a tiny deterministic window, and security headers enabled. It lives in its own assembly because the module loader is static per process — a second differently-configured host can't share a process with the main suite. ## Test data discipline - **Don't share state across tests.** The whole suite shares one database, and a single collection means hundreds of tests touch the same rows over a run. Use unique names per test (`UniqueName("...")` / a `Guid` suffix) so tests stay independent and re-runnable. - **Don't mutate seeded data.** The root tenant and root admin are shared by every test. Create your own tenant/user/brand when you need to mutate one. - **Don't trust class ordering.** xUnit doesn't guarantee execution order across classes. If a test needs another test to "have created X first," the test is wrong. ## When integration tests get slow A few-minutes integration suite is acceptable in CI but painful locally. Mitigations: - **Run unit tests first** (`dotnet test src/Tests/{Module}.Tests/`) — they're fast and they catch most regressions. - **Use `--filter`** to run just the test you're iterating on: ```bash dotnet test src/Tests/Integration.Tests/ --filter "FullyQualifiedName~BrandsEndpointTests" ``` - **Don't add cross-test sleeps.** "Wait 200 ms for the cache to settle" is almost always wrong; find the deterministic signal and assert against it. ## Running Docker must be running for Testcontainers to work: ```bash # Run the entire integration suite dotnet test src/Tests/Integration.Tests/ # Single test class dotnet test src/Tests/Integration.Tests/ \ --filter "FullyQualifiedName~BrandsEndpointTests" # The production-middleware suite dotnet test src/Tests/Integration.Middleware.Tests/ # Verbose output dotnet test src/Tests/Integration.Tests/ --logger "console;verbosity=detailed" ``` CI runs both integration projects on every backend push — see [CI/CD](/docs/deployment/ci-cd/). ## Related - [Unit tests](/docs/testing/unit-tests/) — for fast in-process tests. - [Architecture tests](/docs/testing/architecture-tests/) — for the structural rule families. - [Fixtures & seed data](/docs/testing/fixtures-and-seed-data/) — how the sharing works in detail. --- # Running tests > Commands and CI patterns for running the unit / integration / architecture suites locally and in CI. Source: https://fullstackhero.net/docs/testing/running-tests/ The kit's test suites run with the standard `dotnet test` command. Three layers, three different speed profiles, one set of commands. Locally, run the unit + architecture suites on save (they're fast). Reserve the integration suite for pre-commit + CI. Most regressions are caught by the fast layers; integration tests verify the gluing between modules + infrastructure. ## Run everything ```bash dotnet test src/FSH.Starter.slnx ``` This runs every test project. Expect: - Unit projects: seconds each - Architecture project: a few seconds — no containers - Integration projects: a few minutes — container startup, migrations, and ~700 real HTTP round-trips (Docker required) ## Run a single project ```bash # Unit tests for one module dotnet test src/Tests/Identity.Tests/ # Architecture tests only — fast, no containers dotnet test src/Tests/Architecture.Tests/ # Integration tests only — Docker must be running dotnet test src/Tests/Integration.Tests/ # Production-middleware integration tests (separate assembly, own process) dotnet test src/Tests/Integration.Middleware.Tests/ ``` ## Filter by name ```bash # Single test class dotnet test --filter "FullyQualifiedName~ProductsEndpointTests" # Single test method dotnet test --filter "FullyQualifiedName~BrandsEndpointTests.CreateBrand_Should_Return200_And_Persist_When_AuthorizedAdmin" ``` The `~` operator does substring match; `=` does exact match. Combine with `&` and `|`: ```bash dotnet test --filter "FullyQualifiedName~Catalog & FullyQualifiedName~Product" ``` ## Parallelism xUnit runs test classes in parallel within a single project by default. Tests within a class run sequentially unless marked otherwise. To disable parallelism across classes (e.g. for shared state): ```ini # xunit.runner.json in the test project root { "parallelizeTestCollections": false } ``` The kit's integration project uses a **collection fixture** — every test class joins `[Collection(FshCollectionDefinition.Name)]`, sharing one `FshWebApplicationFactory` (and one container set) across the whole suite. xUnit runs classes within a collection sequentially, which is why unique test data matters less than raw isolation here — but keep names unique anyway; it costs nothing. ## Verbose output ```bash # Detailed output — useful for debugging slow tests dotnet test --logger "console;verbosity=detailed" # Trx output for CI parsing dotnet test --logger "trx;LogFileName=test-results.trx" # Both dotnet test --logger "console;verbosity=detailed" --logger "trx" ``` ## Code coverage The kit ships `coverlet.collector` in unit projects. Generate a coverage report: ```bash dotnet test src/Tests/Identity.Tests/ --collect:"XPlat Code Coverage" # Generates a .cobertura.xml file under TestResults/ # Convert to HTML with the reportgenerator tool: dotnet tool install --global dotnet-reportgenerator-globaltool reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coveragereport ``` The HTML report lands in `coveragereport/index.html`. CI collects coverage from the unit and integration jobs separately, merges the two with ReportGenerator, and **fails the build below 80% line coverage** — a ratchet that gets raised as coverage grows. See [CI/CD](/docs/deployment/ci-cd/). ## Docker requirement for integration tests Testcontainers needs a running Docker daemon. Locally, Docker Desktop on macOS / Windows, Docker Engine on Linux. In CI, the runner image must have Docker available — GitHub-hosted runners do; self-hosted runners need explicit setup. ```bash # Verify Docker is reachable before running integration tests docker info ``` If Docker isn't available, integration tests fail with `Testcontainers.DockerNotAvailableException`. ## CI — GitHub Actions The kit ships its own path-scoped workflows in `.github/workflows/` — you don't need to write one. On every backend change, `backend.yml` builds with `-warnaserror`, runs the 12 unit + architecture projects and the two integration projects (each with coverage collection), smoke-tests the DbMigrator container, and merges coverage behind an **80% line-coverage gate**. GitHub-hosted runners (`ubuntu-latest`) have Docker pre-installed, so Testcontainers works without extra setup. The full pipeline — jobs, gates, publishing — is documented on the [CI/CD page](/docs/deployment/ci-cd/). Local parity: ```bash dotnet build src/FSH.Starter.slnx -c Release -warnaserror # the CI build gate dotnet test src/FSH.Starter.slnx -c Release # everything (needs Docker) ``` ## Watch mode (local iteration) `dotnet watch` reruns tests on file change — useful while iterating on a single test: ```bash dotnet watch --project src/Tests/Identity.Tests/ test \ --filter "FullyQualifiedName~RegisterUserCommandHandlerTests" ``` It picks up file edits, recompiles, and reruns the filter. Fast feedback loop without retyping the command on every change. ## Container cleanup The kit's containers are created with `WithAutoRemove(true)`, and Testcontainers' reaper (Ryuk) cleans up after crashed runs — so leaks are rare. If a hard kill (machine sleep, Docker restart mid-run) leaves something behind: ```bash # List anything Testcontainers-labelled docker ps -a --filter "label=org.testcontainers" # Remove leftovers docker rm -f $(docker ps -aq --filter "label=org.testcontainers") ``` ## Related - [Unit tests](/docs/testing/unit-tests/) — what runs fastest. - [Integration tests](/docs/testing/integration-tests/) — what runs slowest. - [Writing new tests](/docs/testing/writing-new-tests/) — recipes for each layer. - [Fixtures & seed data](/docs/testing/fixtures-and-seed-data/) — what the integration tests share. --- # Unit tests > xUnit + Shouldly + NSubstitute + AutoFixture for fast in-process tests of handlers, aggregates, and validators — no DbContext, no file system, no network. Source: https://fullstackhero.net/docs/testing/unit-tests/ Unit tests in fullstackhero are **fast, in-process, no infrastructure**. They test domain invariants on aggregates, handler logic with substituted dependencies, and validators against constructed commands. No `DbContext`. No file system. No network. No DI container. Sub-second per project; the entire kit's unit suite runs in a few seconds. ## The stack | Library | Used for | |---|---| | xUnit 2.x | Test runner, `[Fact]` / `[Theory]` discovery | | Shouldly 4.x | Readable assertions — `result.ShouldBe(...)`, `.ShouldThrow<>()`, `.ShouldSatisfyAllConditions(...)` | | NSubstitute 5.x | Mocking service interfaces, verifying calls | | AutoFixture 4.x | Random DTO / fixture generation | All four come from the kit's `Directory.Packages.props` central package management. ## Naming convention `MethodName_Should_ExpectedBehavior_When_Condition`. The shape forces you to write the assertion as a sentence: ```csharp [Fact] public async Task Handle_Should_DelegateToRegistrationService_When_CommandIsValid() { /* ... */ } [Fact] public void ChangePrice_Should_RaiseDomainEvent_When_NewPriceIsDifferent() { /* ... */ } [Fact] public void Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed() { /* ... */ } ``` Three rules: - **`Method_Should_...`** — start with what's being called. - **`_Should_X`** — then what the test asserts. - **`_When_Y`** — then the condition (or omit if the test is a single fact about default behaviour). ## Layout: Arrange / Act / Assert Each test reads top to bottom in three sections, grouped under `#region` blocks if there are many tests per class: ```csharp public sealed class RegisterUserCommandHandlerTests { private readonly Fixture _fixture = new(); private readonly IUserRegistrationService _registration = Substitute.For(); [Fact] public async Task Handle_Should_DelegateToRegistrationService_When_CommandIsValid() { // Arrange var command = _fixture.Build() .With(c => c.Email, "valid@example.com") .With(c => c.Password, "Pa55w0rd!Strong#Enough") .Create(); var expected = new RegisterUserResponse(Guid.NewGuid(), command.Email); _registration.RegisterAsync(command, Arg.Any()) .Returns(ValueTask.FromResult(expected)); // Act var sut = new RegisterUserCommandHandler(_registration); var actual = await sut.Handle(command, CancellationToken.None); // Assert actual.UserId.ShouldBe(expected.UserId); await _registration.Received(1).RegisterAsync(command, Arg.Any()); } } ``` ## Three ground rules ### 1. Use Shouldly, never raw `Assert` ```csharp // good result.ShouldBe(42); result.ShouldNotBeNull(); () => action().ShouldThrow().Message.ShouldContain("conflict"); // not this Assert.Equal(42, result); Assert.True(result != null, "result should not be null"); ``` Shouldly's failure messages carry actual + expected without you writing the assertion message. Less typing, better failure output. ### 2. Construct the SUT directly; no DI container ```csharp var sut = new RegisterUserCommandHandler(_registration); // direct construction // not this using var scope = serviceProvider.CreateScope(); var sut = scope.ServiceProvider.GetRequiredService(); ``` Unit tests test the handler. If your handler is too coupled to its DI container to be unit-tested in isolation, that's a sign to refactor — extract the logic, depend on interfaces. ### 3. No infrastructure, no network, no file system Anything that touches `DbContext`, `HttpClient`, file I/O, or Valkey is an [integration test](/docs/testing/integration-tests/). Unit tests stay in memory. ## Testing aggregates Domain aggregates carry invariants. Test them directly — no handler, no DI: ```csharp public sealed class TicketTests { [Fact] public void Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed() { var ticket = Ticket.Create(number: 1, title: "x", description: null, priority: TicketPriority.Medium, reporterUserId: Guid.NewGuid()); ticket.Resolve(resolutionNote: null); // ... advance to Closed somehow var act = () => ticket.Resolve(resolutionNote: "again"); var ex = act.ShouldThrow(); ex.StatusCode.ShouldBe(HttpStatusCode.Conflict); } [Fact] public void AddComment_Should_RaiseDomainEvent_When_TicketIsOpen() { var ticket = Ticket.Create(/* ... */); var commentId = ticket.AddComment(authorUserId: Guid.NewGuid(), body: "Hello"); ticket.DomainEvents.ShouldContain(e => e is TicketCommentAddedDomainEvent); } } ``` `ChatChannelTests`, `MessageTests`, and the per-module `*Tests` projects in `src/Tests/{Module}.Tests/Domain/` are full of these. They run in milliseconds and they're the most direct way to verify an aggregate's invariants. ## Testing validators ```csharp public sealed class RegisterUserCommandValidatorTests { private readonly IUserService _users = Substitute.For(); private readonly RegisterUserCommandValidator _sut; public RegisterUserCommandValidatorTests() { _users.ExistsAsync(Arg.Any(), Arg.Any()) .Returns(ValueTask.FromResult(false)); _sut = new RegisterUserCommandValidator(_users); } [Theory] [InlineData("")] [InlineData("not-an-email")] public async Task Validate_Should_Fail_When_EmailIsInvalid(string email) { var command = ValidCommandWith(email); var result = await _sut.ValidateAsync(command); result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.PropertyName == nameof(RegisterUserCommand.Email)); } private static RegisterUserCommand ValidCommandWith(string email) => /* ... */; } ``` `[Theory] + [InlineData]` is the right pattern for table-driven validator tests. ## Verifying mock interactions NSubstitute's `Received(n)` checks how many times a method was called: ```csharp await _registration.Received(1).RegisterAsync(command, Arg.Any()); await _registration.DidNotReceive().RegisterAsync(/* different command */, Arg.Any()); ``` Use this to assert "the handler delegated correctly," not just "the result happens to be right." Both matter — a handler that returns the right answer for the wrong reason is a bug waiting to happen. ## Running ```bash # A single module's unit tests dotnet test src/Tests/Identity.Tests/ # Filter by test name dotnet test --filter "FullyQualifiedName~RegisterUserCommandHandlerTests" ``` Unit projects run sub-second; no `xunit.runner.json` tweaks needed. Architecture tests (also fast) are covered separately on the [architecture tests page](/docs/testing/architecture-tests/). ## Related - [Integration tests](/docs/testing/integration-tests/) — when you need real infrastructure. - [Architecture tests](/docs/testing/architecture-tests/) — the NetArchTest rule families. - [Vertical slice architecture](/docs/architecture/vertical-slice/) — the slice shape unit tests mirror. --- # Writing new tests > Step-by-step recipes for adding a unit test, an integration test, or an architecture rule — copy-paste-ready. Source: https://fullstackhero.net/docs/testing/writing-new-tests/ Three recipes — unit test, integration test, architecture rule. Each is copy-paste-ready and follows the conventions the rest of the kit uses. Test the smallest unit that fully exercises the behaviour. An invariant on an aggregate is a domain unit test (no DbContext). An endpoint's HTTP response is an integration test (real round trip). An architectural rule is an architecture test (compile-time predicate). Don't reach for integration when unit will do. ## Recipe 1 — A unit test for a new handler You've just added a handler under `Modules.Catalog/Features/v1/Products/AdjustProductStock/` and want to verify its logic without infrastructure. ### 1. Create the test file Unit test projects group by *kind*, not by feature path — `Domain/` for aggregate tests, `Handlers/` for handler tests, `Services/` for service tests (see `Identity.Tests` for the fullest example): ``` src/Tests/Catalog.Tests/Handlers/AdjustProductStockCommandHandlerTests.cs ``` (If the module has no test project yet, create it with `dotnet new xunit -n Catalog.Tests -o src/Tests/Catalog.Tests`, add it to the solution, and reference the module plus `Shouldly`, `NSubstitute`, and `AutoFixture` — versions come from central package management.) ### 2. Write the test ```csharp namespace Catalog.Tests.Handlers; public sealed class AdjustProductStockCommandHandlerTests { private readonly ICatalogDbContext _db = Substitute.For(); [Fact] public async Task Handle_Should_AdjustStock_And_SaveChanges_When_ProductExists() { // Arrange var product = Product.Create(/* … create a product with Stock: 100 … */); _db.Products.FindAsync(Arg.Is(o => (Guid)o[0] == product.Id), Arg.Any()) .Returns(ValueTask.FromResult(product)); var command = new AdjustProductStockCommand(product.Id, Delta: 5m); var sut = new AdjustProductStockCommandHandler(_db); // Act await sut.Handle(command, CancellationToken.None); // Assert product.Stock.ShouldBe(105m); await _db.Received(1).SaveChangesAsync(Arg.Any()); product.DomainEvents.ShouldContain(e => e is ProductStockAdjustedDomainEvent); } [Fact] public async Task Handle_Should_ThrowNotFound_When_ProductDoesNotExist() { _db.Products.FindAsync(Arg.Any(), Arg.Any()) .Returns(ValueTask.FromResult(null)); var sut = new AdjustProductStockCommandHandler(_db); await Should.ThrowAsync(async () => await sut.Handle(new AdjustProductStockCommand(Guid.NewGuid(), 5m), CancellationToken.None)); } } ``` ### 3. Run it ```bash dotnet test src/Tests/Catalog.Tests/ \ --filter "FullyQualifiedName~AdjustProductStockCommandHandlerTests" ``` Should pass sub-second. ## Recipe 2 — An integration test for a new endpoint You've added `PATCH /api/v1/catalog/products/{productId}/stock` and want to verify the HTTP round-trip. ### 1. Create the test file Integration tests group by module under `src/Tests/Integration.Tests/Tests/`: ``` src/Tests/Integration.Tests/Tests/Catalog/AdjustProductStockEndpointTests.cs ``` ### 2. Write the test Join the shared collection, take the factory in the constructor, and authenticate through `AuthHelper` — that's the whole harness contract (see [Fixtures & seed data](/docs/testing/fixtures-and-seed-data/)): ```csharp using Integration.Tests.Infrastructure; using Integration.Tests.Infrastructure.Extensions; namespace Integration.Tests.Tests.Catalog; [Collection(FshCollectionDefinition.Name)] public sealed class AdjustProductStockEndpointTests { private readonly FshWebApplicationFactory _factory; private readonly AuthHelper _auth; public AdjustProductStockEndpointTests(FshWebApplicationFactory factory) { _factory = factory; _auth = new AuthHelper(factory); } [Fact] public async Task AdjustStock_Should_UpdateStock_When_PayloadIsValid() { using var client = await _auth.CreateRootAdminClientAsync(); // Arrange — create the product this test owns (unique SKU, no shared seed) var createResponse = await client.PostAsJsonAsync( $"{TestConstants.CatalogBasePath}/products", new { sku = $"TEST-{Guid.NewGuid():N}"[..12], name = "Stock test product", priceAmount = 10m, priceCurrency = "USD", stock = 100m, }); createResponse.EnsureSuccessStatusCode(); var productId = await createResponse.DeserializeAsync(); // Act var response = await client.PatchAsJsonAsync( $"{TestConstants.CatalogBasePath}/products/{productId}/stock", new { delta = 5m }); // Assert HTTP, then assert state via GET response.StatusCode.ShouldBe(HttpStatusCode.NoContent); var get = await client.GetAsync($"{TestConstants.CatalogBasePath}/products/{productId}"); var dto = await get.DeserializeAsync(); dto.Stock.ShouldBe(105m); } [Fact] public async Task AdjustStock_Should_Return401_When_NoTokenProvided() { using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("tenant", TestConstants.RootTenantId); var response = await client.PatchAsJsonAsync( $"{TestConstants.CatalogBasePath}/products/{Guid.NewGuid()}/stock", new { delta = 5m }); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } } ``` For permission-denial (403) coverage, create a user without the permission via the API and authenticate with `CreateAuthenticatedClientAsync` — see `Tests/Authorization/PermissionEnforcementTests.cs` for the patterns. ### 3. Run it ```bash dotnet test src/Tests/Integration.Tests/ \ --filter "FullyQualifiedName~AdjustProductStockEndpointTests" ``` Docker must be running — the suite starts its Postgres + MinIO containers once and shares them across every test. ## Recipe 3 — A new architecture rule You've adopted a convention — say, "every Mediator handler must be public sealed" — and want CI to enforce it. ### 1. Pick the file Architecture tests live flat in `src/Tests/Architecture.Tests/`, one file per rule family: ``` src/Tests/Architecture.Tests/HandlerShapeTests.cs ``` ### 2. Write the rule Follow the shipped pattern: iterate `ModuleAssemblyDiscovery.GetModuleAssemblies()`, collect violations, fail with the offending types named: ```csharp namespace Architecture.Tests; public sealed class HandlerShapeTests { /// /// Specification: every Mediator handler must be public sealed. /// Why: source-gen requires concrete types; sealed prevents accidental /// inheritance which would break the generator. /// [Fact] public void Mediator_Handlers_Should_Be_Public_Sealed() { var failures = new List(); foreach (var module in ModuleAssemblyDiscovery.GetModuleAssemblies()) { var handlers = module.GetTypes() .Where(t => t.IsClass && !t.IsAbstract) .Where(t => t.GetInterfaces().Any(i => i.IsGenericType && (i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>) || i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)))); failures.AddRange(handlers .Where(h => !h.IsPublic || !h.IsSealed) .Select(h => h.FullName!)); } failures.ShouldBeEmpty( $"Handlers must be public sealed: {string.Join(", ", failures)}"); } } ``` Three conventions the shipped rules all follow: - **XML doc comment** stating the rule and *why* — the readable specification. - **Failure message that names the offenders** — so the fix is obvious from the CI log. - **Explicit allowlists for exceptions** (see `HandlerValidatorPairingTests.KnownMissingCommandHandlers`) — exemptions are code-reviewed, never silent. For pure dependency rules ("X must not reference Y"), NetArchTest's fluent API is shorter: ```csharp var result = Types.InAssembly(module) .That().ResideInNamespaceContaining(".Domain") .ShouldNot().HaveDependencyOn("Microsoft.EntityFrameworkCore") .GetResult(); result.IsSuccessful.ShouldBeTrue(); ``` ### 3. Run it ```bash dotnet test src/Tests/Architecture.Tests/ \ --filter "FullyQualifiedName~HandlerShapeTests" ``` Runs in seconds — no infrastructure. If it fails, the message names the offending types. ## Three rules of thumb 1. **Smallest possible test.** A domain invariant on an aggregate is a unit test, not an integration test. 2. **One arrangement per test.** If a single test asserts six things across three setups, split it into three tests. 3. **Name the test by what would fail.** `Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed` tells you the intent. `TicketsTest1` doesn't. ## Related - [Unit tests](/docs/testing/unit-tests/) — conventions and assertion style. - [Integration tests](/docs/testing/integration-tests/) — the harness and authenticated clients. - [Architecture tests](/docs/testing/architecture-tests/) — the rule families that ship. - [Running tests](/docs/testing/running-tests/) — `dotnet test` invocations.