# Slicekit > Slicekit is a professional, production-grade SaaS template: an event-driven .NET 10 API (vertical slices, DDD, CQRS, messaging) paired with a typed React 19 SPA (TanStack, shadcn/ui). Passkeys, TOTP, OAuth, granular permissions, a finished admin panel, a hash-chained audit trail, full observability and CI included. Buy once, own the code. This file contains the complete Slicekit documentation as a single Markdown document, grouped by section. --- # Getting Started # Introduction > What Slicekit is, who it is for, and how the documentation is organised. ## What is Slicekit? Slicekit is a premium, full-stack SaaS boilerplate: an event-driven **.NET 10 API** (vertical slice + DDD + CQRS and messaging) paired with a type-safe **Vite + React SPA** (vertical slice + TanStack + shadcn/ui). You buy it once, clone it and make it your own: the starting point for a real product, not a tutorial app. The goal is simple: give you the foundation every SaaS needs (authentication, a typed API client, background messaging, storage, observability and CI), assembled and tested, so your first commit is a feature instead of a framework. ## Who it is for - **Founders and small teams** who want to start from a working system rather than an empty repo. - **Engineers** who like vertical slices, explicit code and a mainstream stack you fully own, with no lock-in. - **Agents and AI tooling.** The codebase ships `AGENTS.md` routers and per-side conventions so automated contributors have the context they need. ## How the docs are organised | Section | What it covers | | --------------- | ------------------------------------------------------------------------ | | Getting Started | What ships in the box, install prerequisites, run the stack, repo layout. | | Concepts | The design: architecture, vertical slices, DDD, CQRS, events and auth. | | Backend guides | How-to recipes for the API: migrations, permissions, OAuth, files, more. | | Frontend | The SPA: structure, the typed client, forms, languages and UI gating. | | Operations | Configuration, observability, deployment and running behind a proxy. | If you are here to try it, jump straight to [Getting started](/docs/getting-started). For a tour of everything that ships, see [what Slicekit includes](/docs/what-slicekit-includes). If you want to understand the design first, read the [architecture overview](/docs/architecture). ## What you own Slicekit is a one-time purchase under a commercial license, not a subscription or a hosted service. Once you buy it, the entire repository is yours: clone it, delete what you do not need, and keep the parts that save you weeks. Lifetime updates are included. See [pricing](/#pricing) for details. # What Slicekit includes > A map of what ships in the box: the foundations every SaaS needs, already assembled and tested, with a guide for each. ## The foundation, already built Slicekit is not an empty repo with a framework bolted on. It is a working system: the parts every SaaS needs are assembled, wired together and tested, so your first commit is a feature instead of plumbing. This page is the map. Each capability links to the guide that shows you how to use and extend it. ## Identity and access - **Cookie sessions with CSRF.** Server-side sessions backed by Redis, not bearer tokens in the browser. See [authentication](/docs/authentication). - **Roles and permissions.** A typed `Allow` permission catalogue enforced on endpoints and mirrored to the SPA. See [adding a permission](/docs/adding-a-permission) and [permissions in the UI](/docs/frontend-permissions). - **OAuth providers.** Sign in with external providers alongside the cookie session. See [adding an OAuth provider](/docs/oauth-provider). - **Two-factor authentication.** TOTP enrollment, verification and recovery codes. See [two-factor authentication](/docs/two-factor-auth). - **Admin impersonation.** Support staff can act as a user, with the action recorded. See [impersonation](/docs/impersonation). ## The architecture - **Vertical slices.** One feature, one folder; no layered free-for-all. See [adding a vertical slice](/docs/vertical-slices) and the [architecture overview](/docs/architecture). - **Domain-driven design.** Aggregates own their invariants and raise events. See [domain-driven design](/docs/domain-driven-design). - **CQRS over Wolverine.** Commands, queries and a message bus, with handlers discovered automatically. See [CQRS and domain events](/docs/cqrs-and-events). - **Reliable messaging.** A transactional outbox makes integration events safe to publish. See [domain and integration events](/docs/domain-events). - **A shared error taxonomy.** `Result` and `AppError` map cleanly onto ProblemDetails responses. See [error handling](/docs/error-handling). ## Data and operations on data - **PostgreSQL with EF Core.** Migrations apply on start in development and as a deploy step in production. See [adding a database migration](/docs/adding-a-migration). - **Pagination.** Shared primitives for paged, sortable lists end to end. See [pagination](/docs/pagination). - **File storage.** An S3-compatible abstraction, MinIO locally and any bucket in production. See [file storage](/docs/file-storage). - **Auditing.** Emit "who did what" events that flow to Loki and an admin audit log. See [auditing](/docs/auditing). - **GDPR tooling.** Per-user data export and erasure built in. See [data export and GDPR](/docs/data-export). ## The frontend - **A typed React SPA.** Vite, TanStack Router and Query, and shadcn/ui. See [frontend overview](/docs/frontend-overview). - **One typed API client.** Cookies and CSRF handled for you, wrapped in TanStack Query. See [the typed API client](/docs/api-client). - **Forms.** React Hook Form and Zod, wired to the client and its server-side validation errors. See [building a form](/docs/forms). - **Internationalisation.** Namespaced translations and a language switcher. See [adding a language](/docs/i18n). ## Production concerns - **Rate limiting.** Named policies you apply per endpoint. See [rate limiting](/docs/rate-limiting). - **API versioning.** Add a `v2` without breaking existing clients. See [adding an API version](/docs/api-versioning). - **Observability.** Traces, metrics and logs over OpenTelemetry into Grafana. See [observability](/docs/observability). - **Configuration by environment variable.** Placeholders in `appsettings.json`, secrets injected from the environment. See [configuration](/docs/configuration). - **Deployment and reverse proxy.** Standard images, OTLP exporters, forwarded-header support. See [deployment](/docs/deployment) and [reverse proxy](/docs/reverse-proxy). - **CI.** GitHub Actions builds, tests and lints both sides on every push. ## Working in it - **Testing.** A fast unit and architecture suite, plus Testcontainers integration tests. See [testing a feature](/docs/feature-testing). - **Removing what you do not need.** A clean recipe for deleting a slice across both sides. See [removing a feature](/docs/removing-a-feature). - **AI-assisted development.** `AGENTS.md` routers and per-side conventions so coding agents have the context they need. See [AI-assisted development](/docs/ai-assisted). Ready to run it? Start with [getting started](/docs/getting-started). # Getting started > Clone the template, bring up local infrastructure, and run both the API and the frontend. ## Prerequisites You will need the following installed: - **.NET 10 SDK** - **Node.js 22+** and **pnpm** - **Docker** (for local infrastructure and the integration test suite) ## Clone the template ```sh git clone https://github.com/slicekit/slicekit.git cd slicekit ``` ## Or scaffold your own project To start a real product rather than explore the template, use the scaffold script instead. It clones the template and renames everything across all three apps (API, SPA and the landing site): project name, namespaces, domains, support email and API-key prefix. It also regenerates dev and prod secrets, writes the env files and reinitialises git: ```sh curl -fsSL https://raw.githubusercontent.com/gwku/slicekit/main/scripts/new-slicekit.sh | bash -s -- \ --name MyApp --domain myapp.com --repo https://github.com/me/myapp.git ``` Run it with `--help` for every flag; anything required but omitted is prompted for interactively. ## 1. Start local infrastructure A single Compose file brings up Postgres, Redis, RabbitMQ, MinIO, Mailpit and the full observability stack: ```sh docker compose up -d ``` This is everything the API depends on at runtime. The services and their ports are listed in [project structure](/docs/project-structure#port-map). ## 2. Run the API ```sh dotnet run --project api/src/Slicekit.Api ``` The API starts on `http://localhost:5076` (and `https://localhost:5077`). Its interactive OpenAPI reference is served at `/scalar`, and applying database migrations happens automatically on start in development. ## 3. Run the frontend ```sh cd frontend pnpm install pnpm dev ``` The SPA starts on `http://localhost:3003`, which matches the `Cors:AllowedOrigins` the API allows by default. ## Verify it works Open `http://localhost:3003` and register an account; you should land on the dashboard. To make that account an admin, add its email to the `Admin:AdminEmails` array in `api/src/Slicekit.Api/appsettings.json`: ```json "Admin": { "AdminEmails": ["you@example.com"] } ``` Set this before you first start the API and the account is an admin the moment you register. If the API is already running, restart it to pick up the change. Open Grafana at `http://localhost:3010` and you will see traces and metrics already flowing for the requests you just made. ## The fast test loop Most of the time you want the quick unit + architecture suite, which runs in a few seconds and needs no Docker: ```sh dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo ``` The full suite, including integration tests backed by Testcontainers, runs with: ```sh dotnet test api/slicekit.slnx --nologo ``` ## Next steps - Learn the [repository layout](/docs/project-structure). - Read the [architecture overview](/docs/architecture). - Add your first feature by following the [vertical slice recipe](/docs/vertical-slices). # Project structure > A tour of the repository: the API, the frontend, infrastructure, docs and CI. ## Repository layout ``` api/ .NET 10 API: slicekit.slnx, src/, tests/, Dockerfile frontend/ Vite + React + TypeScript SPA docs/ How-to guides (api/ and frontend/) deploy/ Observability config (OTel collector, Loki, Tempo, Prometheus, Grafana) .github/ CI workflows docker-compose.yml Local infrastructure docker-compose.prod.yml Production stack ``` The repository is a monorepo with two deployable apps plus the infrastructure and tooling that tie them together. ## The API Under `api/src/` the solution is split by responsibility: - **`Slicekit.Api`** is the host: endpoints, composition root, middleware. - **`Slicekit.Core`** holds features, domain, persistence and the `AppDbContext`. Features live in vertical slices; the domain follows DDD with aggregates that raise events. The two-project split is deliberate: `Slicekit.Core` has no dependency on the web host, and handlers are invoked over Wolverine's message bus, so the API is just one possible host. A CLI tool or a worker can dispatch the same commands without touching HTTP code. See the [architecture overview](/docs/architecture) for how these fit together. ### Solution and dependencies The backend uses the current .NET build conventions, so there is one obvious place to change each kind of setting: - **`slicekit.slnx`** is the solution file in the new XML format. It replaces the old `.sln`: no GUIDs, no duplicated project paths, just a readable list you can diff and merge. The `dotnet` CLI, EF migrations and the test commands all target it directly (`dotnet test api/slicekit.slnx`). - **`Directory.Packages.props`** turns on central package management: every NuGet version is declared once here, and the `.csproj` files reference packages by name only. Bumping a dependency, or keeping every project on the same version, is a one-line edit in one file. - **`Directory.Build.props`** carries the settings shared by all projects: `net10.0`, nullable reference types, implicit usings and the analyzer rules, so no individual `.csproj` repeats them. ## The frontend Under `frontend/src/` the SPA mirrors the API's slice-per-feature shape. Each feature owns its route, its data hooks and its components, and talks to the API through one shared typed client. See the [frontend overview](/docs/frontend-overview). ## Port map The local development services and their ports: | Service | Port | Notes | | ------------ | ------------ | ------------------------------ | | Frontend | 3003 | Vite dev server | | API | 5076 / 5077 | http / https from Kestrel | | Postgres | 5432 | | | Redis | 6379 | | | RabbitMQ | 5672 / 15672 | broker / management UI | | MinIO | 9000 / 9001 | S3 API / console | | Mailpit | 1025 / 8025 | SMTP / web UI | | Grafana | 3010 | | | Prometheus | 9090 | | | Loki | 3100 | | | Tempo | 3200 | | ## Agent-tool support The repository follows the [AGENTS.md](https://agents.md) standard. A root `AGENTS.md` routes to `api/AGENTS.md` and `frontend/AGENTS.md`, each describing the conventions for that side. Claude Code loads it via a one-line `@AGENTS.md` import from `CLAUDE.md`. # AI-assisted development > Why Slicekit is a codebase your AI coding assistant can actually work in, and how to get the most from it. ## Why this matters Coding agents (and new teammates) thrive on structure and fail on surprises. Slicekit is shaped so a model can land working features instead of guessing. None of this is AI-specific magic: the same properties that make the codebase easy for a model make it easy for a human. ## What makes it AI-ready - **`AGENTS.md` routers.** A root `AGENTS.md` points to per-side instruction files (`api/AGENTS.md`, `frontend/AGENTS.md`) that spell out the conventions for each half. These are read natively by Claude Code, Codex, Copilot, Cursor and others, so an agent loads the right context before it writes a line. Claude Code reads them via the `CLAUDE.md` import at the repo root. - **Predictable vertical slices.** Every feature has the same shape in the same place. To add one, an agent copies a slice folder and renames it. There is no bespoke wiring to discover and no layer to thread a change through. - **Types as a safety net.** Static typing on each side, TypeScript on the SPA and C# on the API, means a wrong guess fails at compile time within that codebase, not in production. The model gets fast, precise feedback instead of silent breakage. - **Tests as guardrails.** Architecture tests fail the build when a slice reaches across a boundary, so autonomous edits stay inside the lines you set. Unit and integration tests catch the rest. - **LLM-clean docs.** Every docs page is served as raw Markdown at `/docs/.md` (diagrams and components stripped out), so you can paste clean context into a model or pull it into a tool. The whole set is indexed at [`/llms.txt`](/llms.txt) and concatenated into one file at [`/llms-full.txt`](/llms-full.txt), following the [llms.txt convention](https://llmstxt.org/). - **Explicit over clever.** Named handlers, obvious folders and no hidden magic. Code is written to be read, by the next engineer or the next agent. ## Getting the most from it 1. **Point your agent at the slice you're copying.** "Add a feature like `Features/ApiKeys/CreateApiKey` for X" gives a model a concrete, working template to mirror. 2. **Let the build and tests be the loop.** Run `dotnet test ... --nologo` and `pnpm typecheck` so the agent's mistakes surface immediately. 3. **Feed it the raw docs.** Link the relevant `/docs/.md` pages as context rather than pasting rendered HTML, or hand the agent [`/llms-full.txt`](/llms-full.txt) for the whole set at once. ## Using these docs with AI The whole documentation set is published in the [llms.txt format](https://llmstxt.org/) so an AI assistant can pull it in as context without scraping rendered HTML: - **[`/llms.txt`](/llms.txt)** is a curated index: the site summary plus a linked, sectioned list of every page, each pointing at its clean `/docs/.md` source. Use it when a tool can follow links, or when you want the model to pick the few pages it needs. - **[`/llms-full.txt`](/llms-full.txt)** is the entire documentation concatenated into one Markdown file. Use it when you want the model to have everything at once. How to provide them, depending on the tool: 1. **Chat assistants (ChatGPT, Claude, Gemini).** Paste the URL and ask the model to read it, or download `llms-full.txt` and attach it as a file. A good opener: "Use this as the reference for the Slicekit template: `https://slicekit.dev/llms-full.txt`." 2. **Coding agents (Claude Code, Cursor, Copilot, Windsurf).** Add the URL to the project's context or rules file (for example a `# Docs` line in `AGENTS.md`/`CLAUDE.md` pointing at `/llms.txt`), or save `llms-full.txt` into the repo and reference it. Many agents will fetch the linked `.md` pages on demand from `/llms.txt`. 3. **Retrieval / RAG pipelines.** Ingest `/llms-full.txt` (or crawl the per-page `.md` routes listed in `/llms.txt`); both are plain Markdown with diagrams and components stripped, so chunking is clean. Both files are regenerated on every build from the same docs you are reading, so they never drift. ## A note on how Slicekit itself is built Slicekit is built with AI in the loop, but never on autopilot. AI accelerates the typing; the architecture, the boundaries and the review are human, and the same tests above run on every change. That is the standard the template is designed to help you hold, too: AI-accelerated, engineer-owned. --- # Concepts # Architecture overview > Vertical slice architecture, the .NET projects and the patterns that divide them, the React SPA, and how a feature is added across both sides. ## Vertical slices, in one minute Most codebases are organised by layer: a controllers folder, a services folder, a repositories folder, a models folder. A single feature is then smeared across all of them, so changing one behaviour means editing four places and reading code that serves a hundred unrelated features. Vertical slice architecture turns that ninety degrees. Code is organised by feature, not by technical layer. Everything one use case needs (its request, its logic, its validation, its result) lives together in one folder. The slice you reason about as a human is the slice in the code, so a feature is something you can read, change or delete in one place. The benefits are practical: less coupling between unrelated features, changes that stay local, an obvious place for every piece of code, and a shape a new engineer (or an AI assistant) can learn once and apply everywhere. For the deeper rationale, Jimmy Bogard's [Vertical Slice Architecture](https://www.jimmybogard.com/vertical-slice-architecture/) is the canonical write-up. Slicekit applies this shape on both sides of the wire: the .NET API is slice-per-feature, and so is the React SPA. ## The API, project by project The API is **vertical slice + domain-driven design + event-driven CQRS** on EF Core and PostgreSQL. It is deliberately *not* a clean-architecture onion of many layered projects, and there is *no* repository abstraction: the `AppDbContext` is the unit of work and the repository. The whole API is two source projects, plus four test projects that guard them. - **`Slicekit.Api`** is the host. It owns HTTP: endpoints, middleware, the composition root, OpenAPI. Endpoints are thin adapters that map a route, its policies (authorization, validation, rate limiting, CSRF) and its status codes. - **`Slicekit.Core`** holds everything else: the feature slices under `Features/`, the domain under `Domain/`, persistence under `Persistence/`, and the messaging configuration. It has no dependency on the web host. That one-way dependency is the point. Because handlers are invoked over [Wolverine](https://wolverinefx.net)'s message bus rather than called from controllers, the API is just one possible host. A CLI tool, a background worker or a scheduled job can dispatch the exact same commands without touching any HTTP code. Two domain rules are worth stating up front, because they keep the model honest: - **Aggregates raise events.** Business rules live in aggregates, which enforce their own invariants and *raise* a domain event to record what happened. Raising is a fact inside the domain; it sends nothing. - **Dispatchers publish events.** After the change commits, raised events are *published* to interested handlers and to the transactional outbox. How commands, queries, events and the outbox fit together is its own page: [CQRS and domain events](/docs/cqrs-and-events). At runtime the picture is simple: the SPA talks to the API through one typed client, and the API owns persistence, messaging and telemetry. ## The frontend The SPA mirrors the API's slice-per-feature shape. Each feature is a folder under `features/` that owns its typed client calls, its TanStack Query hooks, its localized Zod schemas and its components. Shared primitives (the UI kit, the API client, theme handling) live in `components/` and `lib/`; anything feature-specific stays inside the feature. It is a Vite + React 19 app in strict TypeScript, with **TanStack Router** for type-safe routing, **TanStack Query** for server state, and **shadcn/ui** on Tailwind. Every request goes through one typed client that handles cookie sessions, the CSRF header and a silent refresh-and-retry on 401. See the [frontend overview](/docs/frontend-overview) and [the typed API client](/docs/api-client). ## Adding a feature, end to end Because both sides share the slice shape, one feature is one slice through the whole system. The path is always the same: 1. **Write the slice in `Slicekit.Core`.** A new folder under `Features/` with the command (a plain record), the handler (the logic, returning a `Result`), validation, and the result type. The handler loads an aggregate, calls a domain method, and the aggregate raises its event. 2. **Map a thin endpoint in `Slicekit.Api`.** Declare the route, its permission, rate limit, validation and CSRF as policy, then hand the command to the Wolverine bus. No logic here. 3. **Mirror the slice on the frontend.** A folder under `features/` with the typed `api.ts` call, the `hooks.ts` query and mutation, localized `schemas.ts`, and the components that render it. 4. **Lean on the guardrails.** Architecture tests fail the build if a slice reaches across a feature boundary, and feature tests run against a real PostgreSQL via Testcontainers. The full step-by-step lives in the two recipes: [adding a vertical slice](/docs/vertical-slices) on the API, and the matching frontend recipe in the docs. ## Proven, not experimental Slicekit runs on mainstream technology with years (some decades) behind it: PostgreSQL, Redis, RabbitMQ, relational data, CQRS, domain-driven design, REST and JWT, on the current stable releases of .NET and React. The patterns are battle-tested and the dependencies are ones you can hire for and reason about. There is no exotic runtime and nothing bleeding-edge that breaks on the next upgrade: modern where it helps you, boring where it protects you. ### What is deliberately absent - **No clean-architecture onion.** Two source projects, not a stack of layered ones. - **No repository pattern.** The `AppDbContext` is the unit of work and the repository. - **No data-migration shims.** Slicekit is a template with no existing data to bridge. - **No bespoke runtime.** Everything is standard .NET, EF Core and Wolverine. # Adding a vertical slice > A step-by-step recipe for adding a new feature, from the command in Slicekit.Core to the thin HTTP endpoint in Slicekit.Api. ## The anatomy of a slice A feature lives in **two deliberately separate projects**. The slice itself, the command, its handler, validation and results, sits in `Slicekit.Core` under `Features/`. The HTTP endpoint is a thin adapter in `Slicekit.Api` under `Endpoints/v1/`: ``` api/src/Slicekit.Core/ Features/ Projects/ CreateProject/ Command.cs // the request, a plain record Handler.cs // the business logic Validator.cs // FluentValidation rules Result.cs // what the handler returns Domain/ Project.cs // the aggregate api/src/Slicekit.Api/ Endpoints/v1/Projects/ CreateProjectEndpoint.cs // route, policies, status codes ``` The split is the point: `Slicekit.Core` has no dependency on the web host. Handlers are invoked over [Wolverine](https://wolverinefx.net)'s message bus, so the API is just one host. A CLI tool, a background worker or a scheduled job can dispatch the exact same commands without touching any HTTP code. ## 1. Define the command A command is a plain record in `Command.cs`: ```csharp namespace Slicekit.Core.Features.Projects.CreateProject; public sealed record CreateProjectCommand(Guid UserId, string Name); ``` ## 2. Write the handler The handler lives next to the command in `Handler.cs` and is discovered by Wolverine, with no registration required. It returns a `Result` so failures map onto the shared error taxonomy: ```csharp public sealed class CreateProjectCommandHandler(AppDbContext db) { public async Task HandleAsync( CreateProjectCommand command, CancellationToken ct = default) { var project = Project.Create(command.UserId, command.Name); // aggregate raises ProjectCreated db.Projects.Add(project); await db.SaveChangesAsync(ct); return new CreateProjectResult(project.Id); } } ``` ## 3. Raise events from the aggregate The aggregate owns its invariants and records what happened by **raising** an event: ```csharp public class Project : AggregateRoot { public static Project Create(Guid ownerId, string name) { var project = new Project { Id = Guid.CreateVersion7(), OwnerId = ownerId, Name = name }; project.Raise(new ProjectCreated(project.Id, name)); return project; } } ``` Raised events are published after the change is saved, to any interested handlers and to the transactional outbox. See [CQRS and events](/docs/cqrs-and-events). ## 4. Map the endpoint The endpoint, in `Slicekit.Api`, translates HTTP into the command and the result into a response. Authorization, validation, rate limiting and CSRF are declared as route policy: ```csharp internal sealed class CreateProjectEndpoint : IEndpoint { public static void Map(IEndpointRouteBuilder routes) => routes.Projects().MapPost("/", HandleAsync) .RequirePermission(Allow.ProjectCreate) .RequireRateLimiting(RateLimitPolicies.Default) .AddEndpointFilter() .RequireCsrf(); private static async Task> HandleAsync( Request request, ClaimsPrincipal principal, IMessageBus bus, CancellationToken ct) { var result = await bus.InvokeAsync( new CreateProjectCommand(principal.TryGetUserId() ?? throw new UnauthorizedAccessException(), request.Name), ct); if (!result.IsSuccess) return result.Error.ToProblem(); return TypedResults.Created($"/projects/{result.Value.Id}", new Response(result.Value.Id)); } } ``` ## 5. Test the slice Unit-test the handler and the aggregate with the fast suite; cover the endpoint with an integration test backed by Testcontainers. Architecture tests enforce that slices do not reach across feature boundaries. ```sh dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo ``` ## Conventions to keep - **One feature, one folder.** Do not scatter a feature across shared layers. - **Endpoints stay thin.** Routing, policies and status codes only; logic belongs in the handler. - **Raise, do not publish, from aggregates.** Dispatchers publish; aggregates raise. - **No repository interfaces.** Use `AppDbContext` directly. # CQRS and domain events > How commands, queries and events flow through Wolverine, and the transactional outbox that makes messaging reliable. ## Commands and queries Slicekit uses Wolverine as its mediator and message bus. A **command** changes state; a **query** reads it. Both are messages, dispatched to a handler discovered by convention. ```csharp // command await bus.InvokeAsync(new SendInvoice(invoiceId)); // query var invoice = await bus.InvokeAsync(new GetInvoice(invoiceId)); ``` Handlers are static methods that take the message plus whatever services they need as parameters. Dependencies are injected per call, so handlers stay easy to test in isolation. ## Raise vs publish The two verbs are not interchangeable, and keeping them distinct keeps the model honest: - **Aggregates raise events.** Raising records a fact inside the domain; it does not send anything. - **Dispatchers publish events.** After the change is committed, raised events are published to handlers and to the outbox. ```csharp // inside the aggregate project.Raise(new ProjectArchived(project.Id)); // the infrastructure publishes it after SaveChanges await publisher.PublishAsync(raisedEvent); ``` ## The transactional outbox Publishing an event and committing the database change must not drift apart. Wolverine's outbox stores outgoing messages in the same transaction as the state change, then relays them to RabbitMQ once the transaction commits. If the process dies mid-flight, the relay picks up where it left off: no lost messages, no double sends. ## Integration events Some events cross service boundaries. Those are published to RabbitMQ as **integration events** and consumed by handlers, in this app or another. The outbox guarantees at-least-once delivery: an event is never lost, and consumers are written idempotent so a rare redelivery is harmless. ## Handling an event A handler subscribes simply by accepting the event type: ```csharp public static class WhenProjectArchived { public static async Task Handle(ProjectArchived e, AppDbContext db, CancellationToken ct) { // react: revoke access, send a notification, update a read model… } } ``` ## Where audit fits Audit events ride the same logging pipeline rather than a bespoke table; they are emitted through Serilog and shipped to Loki. Retention is an operations concern, not an application one. See [observability](/docs/observability). # Domain-driven design > Aggregates, invariants and domain events: how the domain model is structured and why handlers stay thin. The domain model is the part of Slicekit that knows the rules. It lives in `Slicekit.Core/Domain/`, depends on nothing but the language, and is where every invariant is enforced. Handlers stay thin precisely because the aggregates are not: a handler loads an aggregate, calls one method, and saves. The decision of whether the change is even allowed happens inside the model. This is the concept page. For the end-to-end recipe (command, handler, endpoint), see [adding a vertical slice](/docs/vertical-slices). ## Aggregates own their state An aggregate is a cluster of objects treated as one unit, with a single root that guards the whole. In Slicekit, `User` is the aggregate root for permissions, consents, API keys and refresh tokens. You never load a `RefreshToken` on its own and mutate it: you load the `User` and ask it to change. The shape is deliberate. State is exposed read-only, and every mutation goes through a method: ```csharp public class User : AggregateRoot { private User() { } private readonly List _permissions = []; public IReadOnlyCollection UserPermissions => _permissions; public string Email { get; private set; } = string.Empty; public bool IsAdmin { get; private set; } public void ChangeAdminStatus(Guid actorId, bool isAdmin) { if (IsAdmin == isAdmin) return; IsAdmin = isAdmin; Raise(new UserAdminStatusChangedEvent(actorId, Id, isAdmin)); } } ``` Three rules show up in every aggregate: - **Properties use `private set` or `init`.** Callers read the current `Email`; they cannot assign a new one. `UpdateEmail` does. - **Collections are `IReadOnlyCollection` over a private `List`.** The backing list is invisible outside the class, so nobody can add a permission behind the aggregate's back. - **Mutation lives in named methods.** `ChangeAdminStatus`, `AssignPermission`, `RecordLogin`. The method name is the vocabulary of the domain, and it is the only door in. The `DomainEncapsulationTests` architecture suite enforces this. A public setter or a publicly exposed mutable collection fails the build, so the convention cannot quietly rot. ## The AggregateRoot base Aggregates inherit `AggregateRoot` from `Slicekit.Core/Domain/Primitives/`. The base is small and does two things: it gives the root identity-based equality (two `User` instances are equal when their `Id` matches, regardless of loaded state), and it carries the list of domain events the aggregate has raised. ```csharp public abstract class AggregateRoot : IAggregateRoot { private readonly List _events = []; public IReadOnlyList Events => _events; protected void Raise(IDomainEvent @event) => _events.Add(@event); public void ClearEvents() => _events.Clear(); } ``` `Raise` is `protected`: only the aggregate itself can record an event, and it does so synchronously with no I/O. The events sit on the instance until persistence flushes them. More on that below. ## Invariants are enforced in the method An invariant is a truth that must always hold. The job of an aggregate method is to refuse any call that would break one, before any state changes. ```csharp public bool AssignPermission(Guid actorId, Permission permission) { var exclusion = _exclusions.FirstOrDefault(x => x.PermissionId == permission.Id); if (exclusion is not null) _exclusions.Remove(exclusion); if (HasPermission(permission.Id)) return exclusion is not null; _permissions.Add(new UserPermission { UserId = Id, PermissionId = permission.Id }); Raise(new PermissionAssignedEvent(actorId, Id, permission.Name)); return true; } ``` Note what the handler never has to do: check for a duplicate permission, reconcile an exclusion, or decide whether an event is warranted. The aggregate guarantees that a permission is assigned at most once, and that an event is raised only when something actually changed. Because the check and the mutation are in the same method, you cannot end up with a `User` in an illegal state. ## Child entities mutate through the root `UserPermission`, `UserConsent`, `RefreshToken` and `ApiKey` belong to the `User` aggregate. Their constructors are non-public, so nothing outside the domain can new one up: ```csharp public sealed class UserConsent { internal UserConsent() { } public required Guid UserId { get; init; } public required ConsentType ConsentType { get; init; } public required string Version { get; init; } = string.Empty; public DateTimeOffset GrantedAtUtc { get; init; } = DateTimeOffset.UtcNow; internal static UserConsent Grant(Guid userId, ConsentType type, string version) => new() { UserId = userId, ConsentType = type, Version = version }; } ``` The root constructs them through its own methods (`GrantConsent` calls `UserConsent.Grant`). Keeping construction inside the boundary keeps the invariants local: every rule about consents lives next to the consents, not scattered across handlers. ## Value objects Some concepts are defined entirely by their values, not by an identity. Two money amounts of 10 EUR are the same money; two users with the same name are still two users. The base for the former is `ValueObject`, which derives equality from the values you declare: ```csharp public abstract class ValueObject : IEquatable { protected abstract IEnumerable GetEqualityComponents(); public bool Equals(ValueObject? other) => other is not null && GetType() == other.GetType() && GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); } ``` A subclass yields its parts from `GetEqualityComponents`, and structural equality follows for free. Use a value object when a concept has rules but no lifecycle of its own (an email address, a date range, a slug). Use an entity when it has identity and changes over time. ## Raised events connect the model to the rest When an aggregate decides something happened, it records the fact by **raising** a domain event. The two verbs are not interchangeable and the distinction is load-bearing: - **Aggregates raise.** `Raise(new PermissionAssignedEvent(...))` appends a fact to the aggregate's in-memory `Events` list. No message is sent; no side effect runs. - **Dispatchers publish.** After `SaveChangesAsync` commits, the infrastructure scrapes the raised events off every tracked aggregate and publishes them to handlers and to the transactional outbox. If you ever reach for `bus.Publish(...)` inside an aggregate method, stop. The aggregate raises; the dispatcher publishes after the change is durable. This keeps the domain free of infrastructure and guarantees an event is never sent for a change that was rolled back. ```csharp // inside the aggregate: a fact is recorded Raise(new PermissionRevokedEvent(actorId, Id, permission.Name)); // later, after the transaction commits, the dispatcher publishes it ``` The full flow (outbox, RabbitMQ, idempotent consumers) lives in [CQRS and domain events](/docs/cqrs-and-events), and the catalog of event types and what subscribes to them is in [domain events](/docs/domain-events). ## What the domain may not touch The domain has no dependency on EF Core, ASP.NET, Wolverine or any application code. Two consequences worth internalizing: - **No persistence is a repository.** Slicekit has no `IRepository`. The handler uses `AppDbContext` directly (`db.Users`, `db.SaveChangesAsync`), and the change tracker is the unit of work. The aggregate knows nothing about how it is stored. - **No EF attributes on domain types.** `[Table]`, `[Column]`, `[Required]` do not belong on `User`. Mapping is configured separately with `IEntityTypeConfiguration`, so the model stays a pure expression of the rules. When the domain genuinely needs something from outside (a breached-password check, the current time), it depends on an interface under `Domain/Services/` and the implementation lives with its adapter. The `LayerDependencyTests` suite blocks any import that would pull infrastructure into the model, so this stays true. # Authentication & permissions > Cookie sessions with CSRF, role and permission checks, admin impersonation and the audit trail. ## Cookie sessions, not bearer tokens Slicekit authenticates browsers with **HTTP-only cookie sessions**, which keeps tokens out of JavaScript and out of `localStorage`. Because cookies are sent automatically, every state-changing request is also protected against CSRF with a token the frontend echoes back. The frontend never handles a raw credential; the typed API client attaches the cookie and the CSRF header for you. See [the API client](/docs/api-client). ## Authorization Authorization is permission-based. Endpoints declare the permission they require, and a check runs before the handler: ```csharp app.MapPost("/api-keys", CreateApiKeyEndpoint.Handle) .RequirePermission(Allow.UserCreateApiKey); ``` There are no roles: permissions are granted individually from a single catalog (the `Allow` class), and API keys carry their own scoped subset. A bulk permissions endpoint lets an admin grant or revoke many at once; the change is audited. ## Admin impersonation Support staff sometimes need to see exactly what a user sees. Slicekit includes **short-lived, audited impersonation**: an admin with the right permission can start a session as another user. The session is time-boxed, clearly flagged, and every impersonated action is recorded in the audit trail. ## Audit trail Security-relevant actions (sign-in, permission changes, impersonation, sensitive mutations) emit **audit events**. These flow through the same Serilog → OTLP → Loki pipeline as the rest of the logs, so there is no audit table to maintain and retention lives in Loki rather than the database. ## Behind a reverse proxy In production the API runs behind a reverse proxy, so it is configured to trust forwarded headers for the scheme and client address. That keeps redirects, cookie `Secure` flags and audited IP addresses correct when TLS terminates at the proxy. ## What you configure - **Roles and their permissions.** Seed the set your product needs. - **Session lifetime and cookie options.** Sensible secure defaults are provided. - **Which actions are audited.** Emit an audit event from any handler that warrants one. --- # Backend guides # Adding a database migration > Create, apply and review EF Core migrations against the AppDbContext, and how migrations run on startup. ## Where migrations live Schema is server-side only, so this is an API-only task with no frontend counterpart. EF Core migrations live in `api/src/Slicekit.Core/Persistence/Migrations/`. The `DbContext` is `AppDbContext`, and the startup project that supplies the design-time configuration is `Slicekit.Api`. Both are required arguments to every `dotnet ef` command. ## 1. Create the migration From `api/`, run the generator and point it at the context and startup project: ```sh dotnet ef migrations add \ --project src/Slicekit.Core \ --startup-project src/Slicekit.Api \ --context AppDbContext ``` Use PascalCase, action-first names that describe the change: `AddUserDisplayName`, `DropRefreshTokenUserAgent`, `AddConsentsSoftDeleteEncryption`. EF writes a `_.cs` file (plus its designer partial) into the `Migrations/` folder. ## 2. How migrations are applied You never run a manual `database update` in normal development. `ApplyMigrationsAsync` in `api/src/Slicekit.Core/Configuration/Database.cs` calls `db.Database.MigrateAsync()` on the host before the app starts serving, then syncs permissions: ```csharp public static async Task ApplyMigrationsAsync(this IHost host) { using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); var permissionSync = scope.ServiceProvider.GetRequiredService(); await permissionSync.SyncAsync(); } ``` That single path covers three environments: - **Local dev**: applied at API startup. Add the migration, then just run the API. - **Tests**: Testcontainers spins up a clean Postgres per fixture and applies every migration from scratch, so a broken migration fails the suite. - **CI and production**: applied at startup. You can also run a separate `dotnet ef database update` step in your pipeline if you prefer to gate deploys on it. For zero-downtime deploys, prefer migrations that are forward-compatible with the previously deployed code. ## 3. Order changes around the outbox Wolverine uses a transactional outbox in Postgres (see [CQRS and events](/docs/cqrs-and-events)). Migrations that touch outbox-adjacent tables or sequences must land **before** the new code rolls out, so in-flight outbox rows still match a schema the workers can deserialize. The safe additive pattern: 1. Add a migration with additive-only changes (new columns nullable, new tables). 2. Deploy. The old code keeps running, but the schema is ready. 3. Land the code change that uses the new shape. 4. If needed, add a second migration that tightens the schema (for example makes the column `NOT NULL`) once all running code populates it. For destructive changes (drop a column, drop a table), reverse the order: stop writing the value first, deploy, then drop. ## 4. Forward-only is the policy If a migration that already shipped to `main` turns out wrong, add a new "undo" migration rather than reaching for `dotnet ef migrations remove` after the fact. `remove` rewrites history and breaks anyone who already pulled `main`. It is only acceptable on a branch that has not been merged yet. ## Custom SQL Use the migration's `migrationBuilder.Sql(...)` for things EF cannot model (functions, triggers, partial indexes). Quote identifiers (`"User"`, not `User`): Postgres folds unquoted identifiers to lowercase. ```csharp migrationBuilder.Sql(""" CREATE INDEX ix_user_active_email ON "User" ("Email") WHERE "DeletedAt" IS NULL; """); ``` ## Configure entities, do not annotate them Do not put EF attributes on domain types. Each aggregate keeps its persistence mapping in an `IEntityTypeConfiguration` under `api/src/Slicekit.Core/Persistence/Configurations/` (`UserConfiguration`, `RefreshTokenConfiguration`, and so on). `AppDbContext.OnModelCreating` already discovers them: ```csharp modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); ``` Adding a configuration file is enough to change the model; the next `migrations add` picks up the diff. This keeps the domain free of EF concerns, a rule enforced by the `Domain_Must_Not_Depend_On` architecture test. The same split between domain and persistence drives the rest of the backend; see [Vertical slices](/docs/vertical-slices) and [Project structure](/docs/project-structure). ## Verify - `dotnet build api/slicekit.slnx`: passes, so the new migration compiles. - `dotnet test api/slicekit.slnx --nologo`: passes. Feature and API tests apply all migrations against a fresh Postgres container, so a broken migration surfaces here. - Open `api/src/Slicekit.Core/Persistence/Migrations/_.cs` and read the generated `Up` and `Down`: the SQL should match what you intended. ## Conventions to keep - **Name action-first in PascalCase.** `AddUserDisplayName`, not `Migration3`. - **Additive before destructive.** Add nullable, deploy, backfill, then tighten or drop. - **Forward-only on `main`.** Fix a bad migration with a new one, never `remove` after merge. - **Map in configurations, not attributes.** Mappings live under `Persistence/Configurations/`. - **Quote Postgres identifiers** in any raw `migrationBuilder.Sql(...)`. # Adding a permission > Define a new permission in the Allow catalogue, assign it to roles, and enforce it on endpoints and in the UI. Permissions in Slicekit are owned by code, not by the database. Every protected endpoint chains `.RequirePermission(Allow.)`, and the SPA mirrors the same catalogue to hide UI the caller cannot use. This guide walks the full loop: define the permission, add it to a role catalogue, enforce it on the API, and gate the matching frontend surface. For the request pipeline that runs the filter, see [authentication](/docs/authentication); for how the slice itself is built, see [adding a vertical slice](/docs/vertical-slices). ## The catalogue `api/src/Slicekit.Core/Permissions/Allow.cs` holds every permission as a strongly-typed `PermissionDefinition` constant. Naming is `Area.Action` in PascalCase, one permission per action (`User.GetMe`, `User.CreateApiKey`, `Admin.ListUsers`). Resist bundling read and write into a single `Manage*` permission: the per-action shape lets API keys be scoped to read-only access. ```csharp public sealed record PermissionDefinition(string Name, bool IsReadOnly = false) { public static implicit operator string(PermissionDefinition p) => p.Name; } ``` Two arrays group the catalogue into roles: - `Allow.UserPermissionCatalog`: granted to every new user on first login. - `Allow.AdminPermissions`: granted on top, to users flagged `IsAdmin`. `Allow.RequiredPermissionsFor(user)` returns the set a caller should hold based on their admin flag, and the sign-in path uses it to backfill missing grants. ## 1. Define the permission Add a `PermissionDefinition` constant under the matching area block in `Allow.cs`. Pass `IsReadOnly: true` when the permission only reads state: both permission-picker UIs use that flag to power their "Read-only" preset. ```csharp public static readonly PermissionDefinition ProjectList = new("Project.List", IsReadOnly: true); public static readonly PermissionDefinition ProjectCreate = new("Project.Create"); ``` ## 2. Add it to a role catalogue Append the constant to `UserPermissionCatalog` or `AdminPermissions` in the same file: ```csharp public static readonly PermissionDefinition[] UserPermissionCatalog = [ UserGetMe, // ... ProjectList, ProjectCreate ]; ``` On the next API startup, `PermissionSyncService` (a hosted service in `Slicekit.Api`) reconciles the `Permissions` lookup table to match the catalogue: it inserts the new row and removes any row whose name no longer appears in code. No EF migration is needed for the catalogue itself, permissions are owned by code. `PermissionSyncService` only maintains the lookup table. It never touches `UserPermissions`. Existing users pick up the new permission on their next successful sign-in, when `PermissionBackfill.EnsureCatalogPermissionsAsync` diffs their grants against `Allow.RequiredPermissionsFor(user)` and inserts what is missing. A signed-in session sees the new claim at the next JWT refresh, or within the two-minute `UserCache` TTL, whichever comes first. ## 3. Enforce it on the endpoint Chain `.RequirePermission(...)` onto the route in `api/src/Slicekit.Api/Endpoints/v1/`. The filter rejects the request with a 403 `ForbiddenError` before the handler runs if the caller's claims do not include the named permission. ```csharp internal sealed class CreateProjectEndpoint : IEndpoint { public static void Map(IEndpointRouteBuilder routes) => routes.Projects().MapPost("/", HandleAsync) .RequirePermission(Allow.ProjectCreate) .RequireRateLimiting(RateLimitPolicies.Default) .RequireCsrf(); } ``` The filter itself is small. It reads the principal's `permission` claims, which the authentication middleware projects at sign-in time (both the JWT bearer and the API-key path): ```csharp internal sealed class PermissionEndpointFilter(PermissionDefinition permission) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next) { if (!ctx.HttpContext.User.HasPermission(permission)) return new ForbiddenError(permission).ToProblem(); return await next(ctx); } } ``` Anonymous routes (`/auth/register`, `/auth/login`) chain `.AllowAnonymous()` instead. The `RequirePermission` filter only runs on authenticated requests. ## 4. Mirror it in the SPA The SPA exposes `Permission` as a const map in `frontend/src/shared/auth/permissions.ts`. It must mirror `Allow.cs` exactly: same names, same casing. Add the matching entry whenever you add or rename a permission server-side. ```tsx // ... ProjectList: 'Project.List', ProjectCreate: 'Project.Create', } as const; ``` Gate UI with the `usePermissions` hook in `frontend/src/shared/hooks/use-permissions.ts`, which reads the current user's `permissions` array and exposes a `has(...)` check: ```tsx function ProjectsToolbar() { const { has } = usePermissions(); return (
{has(Permission.ProjectCreate) && }
); } ``` The frontend gate is a UX nicety, not a security boundary: the API filter is the real enforcement, so a hidden button still cannot reach a protected endpoint. See [frontend permissions](/docs/frontend-permissions) for the consumption pattern in routes and menus. ## 5. Verify ```sh dotnet build api/slicekit.slnx ``` The build fails if you reference an `Allow.` that does not exist, so a typo in the endpoint surfaces immediately. Then start the API and hit the protected endpoint without the relevant claim: expect a 403 problem-details response with `"detail": "Missing permission: Project.Create"`. Restart the API and check the `Permissions` table: the new row appears via `PermissionSyncService` with no migration. On the frontend: ```sh cd frontend && pnpm typecheck ``` This passes once the matching `Permission` entry lands, and fails if a `has(Permission.X)` call references a name you forgot to add. ## Checklist - [ ] `PermissionDefinition` constant added to `Allow.cs` (`IsReadOnly: true` if read-only). - [ ] Appended to `UserPermissionCatalog` or `AdminPermissions`. - [ ] `.RequirePermission(Allow.)` chained on the endpoint. - [ ] Matching entry added to `frontend/src/shared/auth/permissions.ts`. - [ ] UI gated with `usePermissions().has(Permission.)` where relevant. - [ ] `dotnet build` and `pnpm typecheck` pass; lookup row appears after restart. # The settings pattern > Add strongly-typed, validated configuration with the settings pattern and bind it from appsettings and environment variables. ## Why typed settings The API never reads `IConfiguration` directly. Configuration binds from `appsettings.json` (plus environment-specific layers and environment variables) into typed records under `api/src/Slicekit.Core/Settings/`, and consumers inject `IOptions` (or `IOptionsSnapshot` / `IOptionsMonitor` when they need reload semantics). Every section is validated at boot. A missing connection string or a malformed URL fails the host before it accepts traffic, not on the first request that touches the setting. > **Scope: API only.** The frontend reads `VITE_*` env vars at build time. See > [Frontend overview](/docs/frontend-overview). ## Layout One settings class per logical group, all in `api/src/Slicekit.Core/Settings/`: ``` api/src/Slicekit.Core/Settings/ ├── AppSettings.cs Root composition: every section as a property. ├── DatabaseSettings.cs ├── AuthSettings.cs ├── OAuthSettings.cs ├── AdminSettings.cs ├── MessagingSettings.cs ├── CachingSettings.cs ├── EmailSettings.cs ├── SmtpSettings.cs ├── FrontendSettings.cs ├── ApiKeySettings.cs ├── AuditingSettings.cs └── StorageSettings.cs ``` A settings class is a plain class with init-only or settable properties and data-annotation attributes for validation. Here is the whole of `FrontendSettings.cs`: ```csharp using System.ComponentModel.DataAnnotations; namespace Slicekit.Core.Settings; public sealed class FrontendSettings { [Required] public string BaseUrl { get; set; } = string.Empty; } ``` ## 1. Define the record Create `api/src/Slicekit.Core/Settings/Settings.cs`. Use `[Required]`, `[Range]`, `[Url]`, and the rest of `System.ComponentModel.DataAnnotations` for single-field rules: ```csharp using System.ComponentModel.DataAnnotations; namespace Slicekit.Core.Settings; public sealed class WidgetSettings { [Required, Url] public string Endpoint { get; init; } = string.Empty; [Range(1, 100)] public int MaxBatchSize { get; init; } = 10; } ``` A handy convention used by several classes is a static `SectionName` property, so the section name lives next to the type that owns it: ```csharp public static string SectionName => "Widget"; ``` ## 2. Nest it under the root Add a property to `AppSettings.cs` so the section nests under the composed root: ```csharp public sealed class AppSettings { public DatabaseSettings Database { get; set; } = new(); // ... public WidgetSettings Widget { get; set; } = new(); } ``` ## 3. Add placeholder values to appsettings.json `appsettings.json` is committed and holds placeholder values only, never real secrets. Add your section with values safe for local dev: ```json { "Widget": { "Endpoint": "http://localhost:9999", "MaxBatchSize": 10 } } ``` ## 4. Register it Add one line to `api/src/Slicekit.Core/Configuration/Settings.cs`, inside `ConfigureSettings`: ```csharp builder.AddSettings(WidgetSettings.SectionName); ``` That private `AddSettings` helper wires up binding, `ValidateDataAnnotations()` and `ValidateOnStart()` in one shot: ```csharp private static OptionsBuilder AddSettings(this IHostApplicationBuilder builder, string section) where T : class => builder.Services.AddOptions() .BindConfiguration(section) .ValidateDataAnnotations() .ValidateOnStart(); ``` The API project picks up the whole set through a single `.ConfigureSettings()` call in `api/src/Slicekit.Api/Program.cs`. No per-section registration in the host. ## 5. Inject it Depend on `IOptions` wherever you need the values. Reach for `IOptionsSnapshot` when you want per-request reload, or `IOptionsMonitor` for change notifications: ```csharp public sealed class WidgetClient(IOptions options) { private readonly WidgetSettings _settings = options.Value; // ... } ``` ## Cross-field rules: implement IValidatableObject Data annotations cover one field at a time. For invariants that span fields ("B is required when flag A is on") or to validate nested settings objects (which `ValidateDataAnnotations()` does not walk by default), implement `IValidatableObject` on the settings class. The same `AddSettings` registration picks it up automatically. `EmailSettings` is the worked example in the repo. It carries a nested `SmtpSettings` and only demands a host and a from-address once email is switched on: ```csharp public sealed class EmailSettings : IValidatableObject { public static string SectionName => "Email"; public bool Enabled { get; init; } public SmtpSettings Smtp { get; init; } = new(); public IEnumerable Validate(ValidationContext validationContext) { if (Smtp.Port is <= 0 or > 65535) yield return new ValidationResult( "Email:Smtp:Port must be between 1 and 65535", [$"{nameof(Smtp)}.{nameof(SmtpSettings.Port)}"]); if (!Enabled) yield break; if (string.IsNullOrWhiteSpace(Smtp.Host)) yield return new ValidationResult( "Email:Smtp:Host is required when Email:Enabled is true", [$"{nameof(Smtp)}.{nameof(SmtpSettings.Host)}"]); if (string.IsNullOrWhiteSpace(Smtp.FromEmail)) yield return new ValidationResult( "Email:Smtp:FromEmail is required when Email:Enabled is true", [$"{nameof(Smtp)}.{nameof(SmtpSettings.FromEmail)}"]); } } ``` Each yielded `ValidationResult` becomes a separate startup error, with the member name(s) attributing the violation. Keeping the rules in the settings class means a single file describes both the shape and its invariants. ## Secrets and overrides `appsettings.json` ships placeholders. Real values arrive from environment variables at runtime, using ASP.NET's `__` separator to walk into nested sections: | Setting | Env var | |-----------------------------|------------------------------| | `Database:ConnectionString` | `Database__ConnectionString` | | `Auth:Jwt:SigningKey` | `Auth__Jwt__SigningKey` | | `Widget:Endpoint` | `Widget__Endpoint` | The repo ships no Key Vault, AWS Secrets Manager, or similar scaffolding. Host operators wire env vars however their platform prefers (Docker secrets, Kubernetes secrets, GitHub Actions, and so on). `.env.prod.example` at the repo root lists every variable a production deploy needs and is consumed by `docker-compose.prod.yml`. When you add a new section, list its env-var name(s) there. See [Deployment](/docs/deployment) for how those values flow into the production stack. ## Validation at boot Combined with `ValidateOnStart()`, both data annotations and `IValidatableObject` run when the host starts. Malformed config fails the API at boot rather than at first use: ```sh dotnet run --project api/src/Slicekit.Api ``` If a required setting is missing or invalid, the host throws before it accepts traffic, and the error names the offending section and member. ## Conventions to keep - **One class per section.** Group related keys; nest the property on `AppSettings`. - **Placeholders, never secrets, in `appsettings.json`.** Real values come from env vars at runtime. - **Register once in `Settings.cs`.** A single `AddSettings` line wires binding plus validation. - **Inject `IOptions`, not `IConfiguration`.** Consumers never read raw configuration. - **Cross-field rules live on the settings class** via `IValidatableObject`, beside the shape. - **Document new env vars in `.env.prod.example`** so deploy operators know what to set. New here? Start with [Getting started](/docs/getting-started) to run the API locally before changing its configuration. # Adding an OAuth provider > Wire up an external OAuth provider (Google, GitHub, ...) alongside the cookie sessions, end to end. ## How OAuth fits in Slicekit authenticates with cookie sessions (see [Authentication](/docs/authentication)). OAuth sits beside that: a provider proves who the user is, and Slicekit either signs in an existing identity or links the external account to the current one. Every provider is an `IOAuthProvider` implementation registered in DI. The generic endpoints under `Slicekit.Api/Endpoints/v1/Auth/` (`OAuthStartEndpoint`, `OAuthCallbackEndpoint`, `LinkOAuthStartEndpoint`, `LinkOAuthCallbackEndpoint`) drive every provider, so adding one never means writing a new endpoint. Adding a provider is four touch-points: a settings block, a provider class, one DI line, and a frontend entry. This guide adds **Discord** as the worked example. ## 1. Add provider settings In `api/src/Slicekit.Core/Settings/OAuthSettings.cs`, add a property for the new provider next to `Google` and `GitHub`: ```csharp public OAuthProviderSettings Discord { get; set; } = new(); ``` Each provider reuses the shared `OAuthProviderSettings` record (`Enabled`, `ClientId`, `ClientSecret`, `RedirectUri`), whose `IsConfigured` check guards the three credentials. Add a matching rule to `Validate()` so a misconfigured provider fails at boot rather than at first login: ```csharp if (Discord.Enabled && !Discord.IsConfigured) yield return new ValidationResult( "OAuth:Discord requires ClientId, ClientSecret, and RedirectUri when Enabled", [nameof(Discord)]); ``` Add the section to `api/src/Slicekit.Api/appsettings.json` with placeholder values. The dev `ClientSecret` stays empty: real secrets come from env vars, never from the committed file. ```json "Discord": { "Enabled": false, "ClientId": "", "ClientSecret": "", "RedirectUri": "https://localhost:5077/api/v1/auth/discord/callback" } ``` List the production env vars in `.env.prod.example`. The double-underscore maps onto the `OAuth:Discord:*` config keys: ```sh OAuth__Discord__Enabled=true OAuth__Discord__ClientId= OAuth__Discord__ClientSecret= OAuth__Discord__RedirectUri= ``` ## 2. Implement `IOAuthProvider` Create `api/src/Slicekit.Api/Auth/OAuth/DiscordOAuthProvider.cs`. The interface (in `IOAuthProvider.cs`) is small: build the authorization URL, exchange the code for tokens, and read back user info. `GitHubOAuthProvider` is the closest template for a non-OIDC provider. ```csharp public sealed class DiscordOAuthProvider( IHttpClientFactory httpClientFactory, IOptions oauth) : IOAuthProvider { public string Provider => "Discord"; public (string url, string codeVerifier) BuildAuthorizationUrl(string state) { var cfg = oauth.Value.Discord; var (verifier, challenge) = PkceHelper.Generate(); var url = $"https://discord.com/oauth2/authorize?response_type=code" + $"&client_id={Uri.EscapeDataString(cfg.ClientId)}" + $"&redirect_uri={Uri.EscapeDataString(cfg.RedirectUri)}" + $"&scope={Uri.EscapeDataString("identify email")}" + $"&state={Uri.EscapeDataString(state)}" + $"&code_challenge={challenge}" + $"&code_challenge_method=S256"; return (url, verifier); } public async Task ExchangeCodeAsync(string code, string codeVerifier, CancellationToken ct) { // POST to the provider token endpoint; return OAuthTokens, or null on failure. } public async Task GetUserInfoAsync(string accessToken, CancellationToken ct) { // GET userinfo; return OAuthUserInfo(externalId, email), or null. // externalId must be the provider's stable user ID, not the email. } } ``` The records the methods hand back live alongside the interface: ```csharp public sealed record OAuthTokens(string AccessToken, string? RefreshToken, string? IdToken = null); public sealed record OAuthUserInfo(string ExternalId, string Email); ``` Three invariants matter: - **`Provider` is the storage key.** The string is persisted in the `OAuthProviderLinks` table and is the lookup key in `OAuthProviderFactory` (it lower-cases on read, so casing is cosmetic but the value is not). Once used in production, never rename it, or existing links break. Capitalise it as a proper noun (`"GitHub"`, not `"github"`). - **`ExternalId` must be stable.** Use the provider's opaque or numeric user ID, never the email address, which a user can change. - **OIDC providers validate the ID token.** Override the default `ValidateIdTokenAsync` and check the ID token against the provider's JWKS using `OidcIdTokenValidator`. See `GoogleOAuthProvider` for that pattern. Non-OIDC providers like GitHub and Discord leave the default no-op in place. `PkceHelper.Generate()` returns a `(verifier, challenge)` pair. Use the challenge in the URL when the provider supports PKCE; if it does not (GitHub, for example), still generate the verifier for interface consistency and ignore the challenge. ## 3. Register in DI In `api/src/Slicekit.Api/Configuration/Auth.cs`, inside the `if (oauthSettings.Enabled)` block, add one line beside the existing Google and GitHub registrations: ```csharp if (oauthSettings.Discord.Enabled) builder.Services.AddScoped(); ``` `OAuthProviderFactory` is registered right after and discovers every `IOAuthProvider` from the container, so no further wiring is needed. A provider that is configured but not `Enabled` is simply never registered. ## 4. Update the frontend Add the provider to the union type and the static list in `frontend/src/features/oauth/types.ts`: ```ts { id: 'google' as OAuthProviderName, label: 'Google' }, { id: 'github' as OAuthProviderName, label: 'GitHub' }, { id: 'discord' as OAuthProviderName, label: 'Discord' }, ]; ``` Give it an icon in `frontend/src/features/oauth/components/OAuthButtons.tsx`: ```ts const ICONS: Record = { google: FcGoogle, github: FaGithub, discord: FaDiscord, }; ``` The button components filter to the providers returned by `GET /api/v1/auth/features`, so the Discord button appears only when `OAuth:Discord:Enabled` is true. No conditional rendering beyond what is already there. ## 5. Whitelist the redirect URI In the provider's own OAuth app console, register the exact redirect URI from your settings. For local dev that is: ```sh https://localhost:5077/api/v1/auth/discord/callback ``` The generic `OAuthCallbackEndpoint` handles the return leg for every provider, so there is no new endpoint file. ## Verify - `dotnet build api/slicekit.slnx` passes. - With `OAuth:Enabled` and `OAuth:Discord:Enabled` true and the three credentials set, `dotnet run --project api/src/Slicekit.Api` starts cleanly. ValidateOnStart rejects a half-configured provider at boot. - Log in via the new provider and confirm the account appears under Settings, Security, Linked accounts. - Repeat from an existing session to confirm account-linking attaches the provider to the current identity. See [Getting started](/docs/getting-started) for bringing the API and frontend up together, and [Authentication](/docs/authentication) for how the resulting session cookie behaves. ## Checklist - [ ] `OAuthProviderSettings` property added to `OAuthSettings` with a matching `Validate()` rule. - [ ] `appsettings.json` placeholder block and `.env.prod.example` env vars added. - [ ] `IOAuthProvider` implementation created, with a stable `Provider` name and `ExternalId`. - [ ] OIDC providers override `ValidateIdTokenAsync`; non-OIDC leave the default. - [ ] DI line added inside the `oauthSettings.Enabled` block in `Auth.cs`. - [ ] Frontend `types.ts` union, list, and `OAuthButtons.tsx` icon updated. - [ ] Redirect URI whitelisted in the provider's OAuth app console. - [ ] Build passes; login and account-linking both verified. # Two-factor authentication > How time-based one-time password (TOTP) two-factor authentication works, and how to enroll, verify and recover. Slicekit ships optional two-factor authentication using TOTP (Time-based One-Time Password, RFC 6238): the six-digit codes that authenticator apps like 1Password, Google Authenticator or Aegis generate. It builds directly on ASP.NET Core Identity, which owns the secret, the recovery codes and the code verification. The feature layers a login challenge and enrollment flow on top. For the password and session machinery underneath, see [Authentication](/docs/authentication). ## The feature gate The whole feature is gated by `Auth:TotpEnabled` (default `true`). When it is off, the TOTP endpoints are never mapped and the frontend hides the 2FA section entirely. ```json // appsettings.json "Auth": { "TotpEnabled": true, "TotpRequired": false } ``` Override at runtime with `Auth__TotpEnabled=false`. In `RegisterEndpoints.cs`, the registration is conditional: ```csharp if (auth.TotpEnabled) v1.MapV1TotpEndpoints(); ``` `GET /api/v1/auth/features` reflects the same flags, so the SPA reads `totpEnabled` from its features hook and only renders the setup card when the feature is on. Setting `TotpRequired: true` additionally forces enrollment: an endpoint filter (`TotpSetupRequiredEndpointFilter`) blocks normal endpoints for users who have not set up 2FA, except the few setup routes that opt out with `AllowWithoutTotpSetup()`. ## Five slices, five endpoints Each operation is a vertical slice in `Slicekit.Core/Features/Auth/` with a thin endpoint in `Slicekit.Api/Endpoints/v1/`. See [Adding a vertical slice](/docs/vertical-slices) for the pattern. | Method | Route | Auth | Slice | | -------- | --------------------------- | --------- | ------------------------ | | `POST` | `/api/v1/me/totp/setup` | Bearer | `SetupTotp` | | `POST` | `/api/v1/me/totp/confirm` | Bearer | `ConfirmTotp` | | `DELETE` | `/api/v1/me/totp` | Bearer | `DisableTotp` | | `POST` | `/api/v1/auth/totp/verify` | Anonymous | `VerifyTotp` | | `POST` | `/api/v1/auth/totp/recover` | Anonymous | `RedeemTotpRecoveryCode` | ## Enrollment Enrollment is two calls. The user is already signed in, so both setup routes require a bearer session (`Allow.UserGetMe`), a confirmed email, and CSRF. **Step 1: begin setup.** `POST /api/v1/me/totp/setup` resets the user's authenticator key in Identity and returns the raw secret plus an `otpauth://` URI that the SPA renders as a QR code. ```csharp // SetupTotp/Handler.cs await userManager.ResetAuthenticatorKeyAsync(user); var key = await userManager.GetAuthenticatorKeyAsync(user); var qr = $"otpauth://totp/{Uri.EscapeDataString(Issuer)}:{Uri.EscapeDataString(user.Email!)}" + $"?secret={key}&issuer={Uri.EscapeDataString(Issuer)}&digits=6&period=30"; return new SetupTotpResult(key, qr); ``` ```json // Response { "secretBase32": "JBSWY3DPEHPK3PXP", "qrCodeUri": "otpauth://totp/Slicekit:user@example.com?secret=..." } ``` Setting `TwoFactorEnabled` does not happen here. The key is provisioned but inert until the user proves they can read codes from it. **Step 2: confirm.** The user scans the QR code, then sends back the first six-digit code. `POST /api/v1/me/totp/confirm` verifies it, flips `TwoFactorEnabled` on, generates ten one-time recovery codes, and raises `TotpEnabledEvent`. ```csharp // ConfirmTotp/Handler.cs var ok = await userManager.VerifyTwoFactorTokenAsync( user, TokenOptions.DefaultAuthenticatorProvider, command.Code); if (!ok) return (new TotpInvalidError(), messages); await userManager.SetTwoFactorEnabledAsync(user, true); var codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); messages.Add(new TotpEnabledEvent(user.Id)); ``` ```json // Request // Response { "code": "123456" } { "recoveryCodes": ["a1b2-c3d4", "..."] } ``` The recovery codes are returned exactly once. Identity stores only their hashes, so the API can never show them again. The SPA displays them in a one-time dialog and tells the user to save them. ## Login challenge When a user with 2FA enabled signs in, the login handler detects `TwoFactorEnabled` and refuses to issue a session. Instead the login endpoint returns a `TotpRequiredError` carrying a short-lived pending token (a JWT, not a session cookie): ```csharp // LoginEndpoint.cs if (result.Value.TotpRequired) { var pendingToken = tokenService.GenerateTotpPendingToken(result.Value.UserId); return new TotpRequiredError( pendingToken, TotpAvailable: true, PasskeyAvailable: result.Value.PasskeyAvailable).ToProblem(); } ``` The pending token only unlocks the verify and recover endpoints. It cannot authenticate any normal endpoint, so possession of it alone gets an attacker nowhere. The client completes login by posting that token plus a code. `POST /api/v1/auth/totp/verify` validates the token, verifies the code via Identity, and only then issues the real session: ```csharp // TotpVerifyEndpoint.cs var pending = await tokenService.ValidateTotpPendingTokenAsync(request.PendingToken); if (pending is null) return new UnauthorizedError().ToProblem(); var result = await bus.InvokeAsync( new VerifyTotpCommand(pending.UserId, request.Code), ct); if (!result.IsSuccess) return result.Error.ToProblem(); await sessionIssuer.IssueAsync(httpContext, response, result.Value.UserId, result.Value.Permissions, ct); await tokenService.MarkTotpPendingTokenUsedAsync(pending.TokenId); return TypedResults.NoContent(); ``` ```json // Request { "pendingToken": "eyJ...", "code": "123456" } // 204 No Content, session cookies set ``` A successful verify resets the access-failed count, records the login on the domain user, and issues the session. The response is a bare `204` with the session cookies attached. ## Recovery If the user loses their authenticator, they redeem one of the codes from enrollment instead. `POST /api/v1/auth/totp/recover` takes the same pending token plus a recovery code: ```csharp // RedeemTotpRecoveryCode/Handler.cs var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(appUser, command.Code); if (!result.Succeeded) { /* throttle, then */ return (new TotpInvalidError(), messages); } ``` ```json // Request { "pendingToken": "eyJ...", "recoveryCode": "a1b2-c3d4" } // 204 No Content, session cookies set ``` Identity consumes the redeemed code, so each one works exactly once. Recovery codes are not auto regenerated. A user who burns through them should disable and re-enroll to get a fresh set. ## Disabling `DELETE /api/v1/me/totp` turns 2FA off. It requires the account password in the body, even though the caller is already authenticated, so a hijacked session cannot quietly strip protection: ```csharp // DisableTotp/Handler.cs if (user.PasswordHash is null || !await userManager.CheckPasswordAsync(user, command.Password)) return (Errors.InvalidCredentials, messages); await userManager.SetTwoFactorEnabledAsync(user, false); await userManager.ResetAuthenticatorKeyAsync(user); messages.Add(new TotpDisabledEvent(user.Id)); ``` Disabling clears the authenticator key as well, so re-enrolling later starts from a brand new secret. ## Throttling The verify and recover endpoints sit behind the shared `auth` rate-limit policy (the same bucket as login). On top of that, both handlers keep a per-account attempt counter in the hybrid cache so a single account cannot be ground down without tripping a per-account lock: - `Auth:MaxTotpAttemptsPerAccountWindow` (default `5`) caps verify attempts. - `Auth:MaxRecoveryCodeAttemptsPerAccountWindow` (default `5`) caps recovery redemptions. Hitting either limit returns a `LockedError` until the window (`Auth:LockoutMinutes`) elapses; a successful attempt clears the counter. For the broader policy, see [Rate limiting](/docs/rate-limiting). ## Checklist - [ ] `Auth:TotpEnabled` is `true` (the default); set `TotpRequired` if enrollment must be mandatory. - [ ] Enroll with `POST /me/totp/setup`, render the `qrCodeUri`, then `POST /me/totp/confirm` with the first code. - [ ] Capture the ten recovery codes from the confirm response; they are shown once. - [ ] At login, handle `TotpRequiredError`, collect a code, and `POST /auth/totp/verify` with the pending token. - [ ] Offer `POST /auth/totp/recover` as the lost-device path. - [ ] Require the password on `DELETE /me/totp` to disable. # Rate limiting > Apply and tune rate-limit policies on endpoints, and the defaults that ship with the template. ## How it works Rate limiting is a backend concern. A request that exhausts its partition is rejected with HTTP `429 Too Many Requests` before the handler runs. The frontend treats that as just another error: the API client surfaces it and the calling code can show a toast. See [the API client](/docs/api-client) for how 429s reach the UI. Policies are named and defined once, in `api/src/Slicekit.Api/Configuration/RateLimiting.cs`. An endpoint opts into one with `.RequireRateLimiting(RateLimitPolicies.)`. Each policy declares a partition key (who shares a quota) and a limiter (how many requests, over what window). ## Shipped policies Five policies cover the template's needs. Most new endpoints reuse one of these rather than adding a sixth. | Policy | Partition key | Limit | |---|---|---| | `RateLimitPolicies.Default` | `sub` claim, fallback to remote IP | Sliding window: 100 / minute | | `RateLimitPolicies.Anonymous` | Remote IP | Sliding window: 20 / minute | | `RateLimitPolicies.Auth` | Remote IP | Sliding window: 10 / minute | | `RateLimitPolicies.CreateApiKey` | `sub` claim, fallback to remote IP | Sliding window: 10 / minute | | `RateLimitPolicies.ExportData` | `sub` claim, fallback to remote IP | Fixed window: 5 / 24 hours | `Anonymous` and `Auth` partition by IP because the caller is not yet authenticated when those endpoints are hit, so there is no `sub` claim to key on. Everything else partitions by user (`sub`), which means several users behind one NAT do not share a quota. `ExportData` uses a fixed window because the legitimate use is a handful of requests per day. A sliding window would let a determined caller burst across the boundary. ## Applying a policy to an endpoint Add `.RequireRateLimiting(...)` to the route, alongside the other route policies (authorization, validation, CSRF). The endpoint stays thin: it declares the limit, it does not enforce it by hand. See [adding a vertical slice](/docs/vertical-slices) for the full endpoint shape. ```csharp public static void Map(IEndpointRouteBuilder routes) => routes.Auth().MapPost("/register", HandleAsync) .WithName("Auth_Register") .AllowAnonymous() .RequireRateLimiting(RateLimitPolicies.Auth) .ProducesProblem(429); ``` `.ProducesProblem(429)` is mandatory whenever an endpoint declares a rate limit. The 429 is part of the public contract, so it belongs in the OpenAPI document the same way any other failure response does. ## Adding a new policy Reach for a new policy only when none of the five fit. A new policy is two consts and a registration. 1. Add a `const string` on `RateLimitPolicies`: ```csharp public const string PasswordReset = "password-reset"; ``` 2. Register it inside `ConfigureRateLimiting`: ```csharp opts.AddPolicy(RateLimitPolicies.PasswordReset, context => RateLimitPartition.GetSlidingWindowLimiter( $"password-reset:{GetRemoteIp(context)}", _ => new SlidingWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromHours(1), SegmentsPerWindow = 6, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 })); ``` 3. Reference it from the endpoint with `.RequireRateLimiting(RateLimitPolicies.PasswordReset)`. `QueueLimit = 0` makes the limiter reject immediately rather than queue the request. That is the right default for HTTP: a queued request would just hold the connection open. A non-zero queue only makes sense for in-process producer and consumer flows, not request handling. ## Tuning the limits Two knobs do most of the work: the partition key (who shares the quota) and the window type (how the count is spread over time). **Window type.** - Sliding window (`GetSlidingWindowLimiter`) spreads the limit across `SegmentsPerWindow` sub-buckets, which prevents end-of-window bursts. Use it for typical request limits. - Fixed window (`GetFixedWindowLimiter`) is a strict count per window. Use it for slow, expensive operations like `ExportData`. - Token bucket and concurrency limiters are supported by .NET but unused here. Add one only if you need its specific semantics. **Partition key.** - User-scoped operation: key on the `sub` claim, falling back to IP with `?? GetRemoteIp(context)`. The fallback covers misconfigured auth and any endpoint mistakenly tagged with a user policy while anonymous. - Unauthenticated endpoint: key on IP only, since `sub` is absent. - Cross-tenant admin action: prefer `sub`. Keying on IP would let one admin's quota be eaten by another admin behind the same NAT. To change an existing limit, edit `PermitLimit` and `Window` on the policy in question. Nothing else references the numbers. ## Verify - `dotnet build` passes. - Start the API, hit a rate-limited endpoint past its limit, and confirm a `429 Too Many Requests` with a problem-details body. - Check the OpenAPI document at `/scalar`: every endpoint that declares `.ProducesProblem(429)` shows the 429 response. ## Checklist - [ ] Endpoint declares `.RequireRateLimiting(RateLimitPolicies.)`. - [ ] Endpoint declares `.ProducesProblem(429)` to match. - [ ] The chosen policy's partition key fits the endpoint (user-scoped uses `sub`, public uses IP). - [ ] A new policy was added only because none of the five shipped policies fit. - [ ] New policies keep `QueueLimit = 0`. - [ ] `dotnet build` passes and the 429 shows up in `/scalar`. # Pagination > Return paged, sortable list results with the shared pagination primitives, from query to typed client. List endpoints share one shape. A query inherits `PagedRequest`, the handler returns `PagedResult`, the endpoint maps it to a wire `PagedResponse`, and the typed client reads that same shape back. The primitives already ship in `Slicekit.Core.Common` and `Slicekit.Api.Common`. Reuse them. Do not invent a per-endpoint envelope. The canonical example is the admin users list: `api/src/Slicekit.Core/Features/Users/ListUsers/` for the slice and `api/src/Slicekit.Api/Endpoints/v1/Admin/ListUsersEndpoint.cs` for the HTTP adapter. This guide follows that slice end to end. For the slice mechanics in general, see [vertical slices](/docs/vertical-slices). ## The request: PagedRequest `PagedRequest` (in `api/src/Slicekit.Core/Common/PagedResult.cs`) is an abstract record carrying only init-only `Page` and `PageSize`, defaulting to 1 and 20: ```csharp public abstract record PagedRequest { public int Page { get; init; } = 1; public int PageSize { get; init; } = 20; } ``` Your query inherits it and declares only the filter and sort fields of its own slice. `ListUsersQuery` adds a search term, a sort field, a direction and an enabled filter: ```csharp public sealed record ListUsersQuery( string? Search = null, UserSortField Sort = UserSortField.CreatedAtUtc, SortDirection Direction = SortDirection.Desc, bool? Enabled = null) : PagedRequest; ``` `Page` and `PageSize` are inherited, so callers set them with object-initializer syntax rather than through the constructor: ```csharp new ListUsersQuery(search) { Page = 2, PageSize = 50 } ``` ## The validator: PagedRequestValidator Inherit `PagedRequestValidator` and you get the page bounds for free: ```csharp public abstract class PagedRequestValidator : AbstractValidator where T : PagedRequest { protected PagedRequestValidator() { RuleFor(x => x.Page).GreaterThanOrEqualTo(1); RuleFor(x => x.PageSize).InclusiveBetween(1, 100); } } ``` The `PageSize` ceiling of 100 is deliberate: without it a caller can ask for a million rows and time out the database. If a slice has no filter rules of its own, drop the body with a primary-constructor declaration, exactly as `ListUsers` does: ```csharp public sealed class ListUsersQueryValidator : PagedRequestValidator; ``` If a slice does need filter rules, add them in the constructor. To raise the ceiling for one endpoint, re-declare `RuleFor(x => x.PageSize)` in the derived constructor; FluentValidation applies last rule wins for the same property. ## The handler: PagedResult `PagedResult` is the handler return type. The total-page and navigation flags are computed properties on the record, so the handler never works them out inline: ```csharp public sealed record PagedResult( IReadOnlyList Items, int TotalCount, int Page, int PageSize) { public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); public bool HasNextPage => Page < TotalPages; public bool HasPreviousPage => Page > 1; } ``` The handler filters once, counts, orders, then takes the page. Ordering is mandatory: ```csharp public async Task> HandleAsync( ListUsersQuery query, CancellationToken ct = default) { var q = db.Users.AsNoTracking(); if (!string.IsNullOrWhiteSpace(query.Search)) { var search = query.Search.Trim(); if (Guid.TryParse(search, out var id)) q = q.Where(u => u.Id == id); else q = q.Where(u => u.Email!.Contains(search)); } if (query.Enabled is { } enabled) q = q.Where(u => u.Enabled == enabled); var totalCount = await q.CountAsync(ct); q = ApplyOrder(q, query.Sort, query.Direction); var items = await q .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .Select(u => new UserListItem(u.Id, u.Email!, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc)) .ToListAsync(ct); return new PagedResult(items, totalCount, query.Page, query.PageSize); } ``` Two round-trips (one `CountAsync`, one page query) is the right default. Note that `CountAsync` runs on the filtered query but before `ApplyOrder`: counting does not need an `ORDER BY`, and skipping it keeps Postgres from doing a needless sort. ## Why ordering is not optional Postgres returns rows in an unspecified order without an `ORDER BY`, so the same page request can return different rows across calls and pages can overlap. `ListUsers` resolves the sort field and direction to a deterministic key with a switch: ```csharp private static IQueryable ApplyOrder(IQueryable q, UserSortField sort, SortDirection dir) => (sort, dir) switch { (UserSortField.Email, SortDirection.Asc) => q.OrderBy(u => u.Email), (UserSortField.Email, SortDirection.Desc) => q.OrderByDescending(u => u.Email), (UserSortField.LastLoginAtUtc, SortDirection.Asc) => q.OrderBy(u => u.LastLoginAtUtc), (UserSortField.LastLoginAtUtc, SortDirection.Desc) => q.OrderByDescending(u => u.LastLoginAtUtc), (_, SortDirection.Asc) => q.OrderBy(u => u.CreatedAtUtc), _ => q.OrderByDescending(u => u.CreatedAtUtc), }; ``` Default to `CreatedAtUtc` and add a tie-breaker on `Id` if duplicate timestamps are possible. Exposing sort fields as an enum rather than a raw column name keeps the surface closed: a caller can only sort by what you list. ## The endpoint: bind and map The endpoint binds `page`, `pageSize` and the filters straight from the query string, dispatches the query over Wolverine, and maps the Core list item to a public DTO: ```csharp private static async Task, ProblemHttpResult>> HandleAsync( int page = 1, int pageSize = 20, string? search = null, UserSortField sort = UserSortField.CreatedAtUtc, SortDirection direction = SortDirection.Desc, bool? enabled = null, IMessageBus bus = default!, CancellationToken ct = default) { var result = await bus.InvokeAsync>( new ListUsersQuery(search, sort, direction, enabled) { Page = page, PageSize = pageSize }, ct); if (!result.IsSuccess) return result.Error.ToProblem(); var paged = result.Value; var items = paged.Items .Select(u => new UserItem(u.Id, u.Email, u.Enabled, u.CreatedAtUtc, u.LastLoginAtUtc)) .ToArray(); return TypedResults.Ok(new PagedResponse(items, paged.TotalCount, paged.Page, paged.PageSize, paged.TotalPages)); } ``` The shared `Slicekit.Api.Common.PagedResponse` carries the navigation flags and offers a `PagedResponse.From(pagedResult)` factory so an endpoint never recomputes them by hand. When the Core list-item shape is already the wire shape, call `From` directly instead of projecting. The admin endpoint defines its own narrower `PagedResponse` carrying only `TotalPages`, because that is all its client reads. Use whichever matches the contract your client consumes; do not return more fields than the SPA uses. ## The typed client consumes the same shape On the frontend the wire shape is mirrored in `frontend/src/shared/api/types.ts`, one to one with what the admin endpoint returns: ```tsx items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number; }; ``` The client method builds the query string and hands the generic to `apiFetch`, which returns the parsed shape typed (see [the API client](/docs/api-client)): ```tsx listUsers: ( page = 1, pageSize = 20, search?: string, sort: UserSortField = 'CreatedAtUtc', direction: SortDirection = 'Desc', enabled?: boolean, ) => { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort, direction, }); if (search) params.set('search', search); if (enabled !== undefined) params.set('enabled', String(enabled)); return apiFetch(`/api/v1/admin/users?${params}`); }, ``` A TanStack `useQuery` wraps that call. The page and filter values belong in the query key so each page is cached separately, and `placeholderData: (prev) => prev` keeps the previous page visible while the next one loads instead of flashing empty: ```tsx page = 1, pageSize = 20, search?: string, sort: UserSortField = 'CreatedAtUtc', direction: SortDirection = 'Desc', enabled?: boolean, ) { return useQuery({ queryKey: [...adminUsersQueryKey, page, pageSize, search ?? '', sort, direction, enabled ?? 'all'], queryFn: () => adminApi.listUsers(page, pageSize, search, sort, direction, enabled), placeholderData: (prev) => prev, }); } ``` The component reads `data.items` for the rows and `data.totalPages` (or `data.totalCount`) to drive the pager. ## When offset paging is not enough `Skip(n)` grows expensive as `n` grows, because Postgres still reads and discards the skipped rows. For tables that can grow large or be paged deeply (exports, infinite scroll), reach for cursor-based paging: order by a stable key, return a `nextCursor` (typically the last `Id` plus `CreatedAtUtc`), and have the client send it back. Slicekit ships no generic cursor helper. Add one only when a specific endpoint demonstrably needs it. ## Checklist - Query inherits `PagedRequest`; declare only your own filter and sort fields. - Validator inherits `PagedRequestValidator`; keep the `PageSize` ceiling unless you have a reason. - Handler returns `PagedResult`: filter, `CountAsync`, order, `Skip`/`Take`, project. - Ordering is present and deterministic (a stable key, with a tie-breaker if needed). - Endpoint binds `page`/`pageSize` from the query string and maps to the wire `PagedResponse`. - Frontend type mirrors the wire shape; page and filters live in the TanStack query key. - Verify: `?page=2&pageSize=20` returns the next slice, and `?pageSize=10000` is rejected with HTTP 400. # Auditing > Record who did what by emitting audit events, and where the trail is stored and surfaced. ## What auditing gives you Slicekit emits a single, uniform `AuditEvent` envelope for every security-relevant action. Each event is sequenced into a SHA-256 hash chain, rendered as a structured Serilog log line, and exported over OTLP to Loki. From there it is queryable in two places: the in-app admin audit log, and Grafana. There is no audit table in Postgres (only the chain cursor lives there) and no app-side purge job. Retention is enforced by Loki's compactor. See [Observability](/docs/observability) for the full pipeline. The plumbing lives in `api/src/Slicekit.Core/Auditing/`. The two types you touch as a feature author are `IAuditService` and `AuditEvent`. ## Three categories, two ways in Audit events fall into categories defined by `AuditCategory.cs`: | Category | Source | Examples | |---|---|---| | `Security` | `SecurityAuditEventHandler` (subscribes to domain events) plus direct emits from handlers | `User.LoggedIn`, `User.PasswordChanged`, `ApiKey.Created` | | `DataChange` | `AuditingSaveChangesInterceptor` (EF change tracking) | `User.Modified`, `User.PermissionAssigned` | | `Request` | `AuditRequestMiddleware` (every HTTP request) | `Http.GET`, `Http.POST` with status, route, latency | Most of what you need is captured for free. Every authenticated HTTP request is audited by the middleware, every tracked entity change is captured by the EF interceptor, and most success paths are already covered because the aggregate raises a domain event that `SecurityAuditEventHandler` translates into an `AuditEvent`. See [CQRS and events](/docs/cqrs-and-events) for how those events are raised and published. You only emit by hand when an action has no natural domain event, or when you want richer metadata than the interceptor captures. ## Emitting from a handler Inject `IAuditService` and call `EmitAsync`. The interface is one method: ```csharp public interface IAuditService { Task EmitAsync(AuditEvent evt, CancellationToken ct = default); } ``` Here is the real emit in `RevokeUserSessions/Handler.cs`: ```csharp public sealed class RevokeUserSessionsCommandHandler( AppDbContext db, HybridCache cache, IOptions authOptions, IAuditService audit) { public async Task> HandleAsync(RevokeUserSessionsCommand command, CancellationToken ct = default) { // ... revoke the tokens ... await audit.EmitAsync(new AuditEvent( AuditCategory.Security, "Admin.UserSessionsRevoked", AuditOutcome.Success, new AuditActor(UserId: command.ActorId), new AuditResource("User", command.UserId.ToString()), Metadata: new Dictionary { ["count"] = ids.Count }), ct); return ids.Count; } } ``` `EmitAsync` enriches the actor and publishes to the durable `audit-events` Wolverine queue, then returns immediately. Hash chaining and log emission run off the request thread on the queue consumer (`AuditEventHandler`), so emitting never adds latency to the response. Prefer raising a domain event and handling it in `SecurityAuditEventHandler` when the action already produces one: that keeps the audit out of your business logic. Emit directly when there is no event to lean on. ## The fields you fill in `AuditEvent` is an immutable record: ```csharp public sealed record AuditEvent( AuditCategory Category, string Action, AuditOutcome Outcome, AuditActor Actor, AuditResource? Resource = null, string? Reason = null, IReadOnlyDictionary? Metadata = null); ``` - **`Category`** groups the event (`Security`, `DataChange`, `Request`, `DataAccess`). - **`Action`** is a dotted string verb like `User.PasswordChanged` or `Admin.UserSessionsRevoked`. - **`Outcome`** is `Success` or `Failure`. Use `Failure` for denials and failed attempts so the security dashboards and alerts can count them. - **`Actor`** is who did it. Pass `new AuditActor(UserId: ...)` and the rest is filled in for you (see below), or use `AuditActor.System` for background work and `AuditActor.Anonymous` for unauthenticated calls. - **`Resource`** is the thing acted on: `new AuditResource("User", id.ToString())`. - **`Reason`** is free text, surfaced on failures (e.g. a lockout reason). - **`Metadata`** is an optional bag of structured extras. Sensitive keys are redacted before the line is written (see below). `Sequence`, `PrevHash`, and `Hash` are set by `AuditEventHandler`, not by you. Leave them at their defaults. ## What the actor captures (and pseudonymizes) `AuditService.EnrichActor` fills the actor from the current request before publishing, so you do not assemble PII yourself: - **`UserId`** (a Guid pseudonym) from the JWT subject, unless you passed one explicitly. - **`IpHash`**, an HMAC-SHA256 of the remote IP salted with `Auditing:IpHashSalt`, truncated to 16 hex chars. Plaintext IP never reaches Loki. Rotate the salt periodically for forward secrecy. - **`UserAgentFamily`**, a coarse string like `Chrome/Windows`, never the raw UA. - **`TraceId`** from the active activity, so an audit line links back to its trace. - **`OnBehalfOfUserId`**, read from the RFC 8693 `act` claim during admin impersonation: `UserId` is then the user being acted as, and `OnBehalfOfUserId` is the admin really doing it. See [Authentication](/docs/authentication). Plaintext email, IP, and user-agent are never written. This keeps the trail GDPR-safe by default. ## The log line, and redaction `AuditEventHandler` links the chain, persists the cursor, and writes a fixed-shape line: ``` audit / actor= resource= reason= seq= metadata= ``` `metadata=` is JSON serialized from `AuditEvent.Metadata`. The handler re-applies the redaction rules from `Auditing:SensitivePropertyNames` over the metadata, recursively, as a last line of defense: any key matching the sensitive patterns (or a property marked `[Sensitive]`) is replaced with the redaction marker before it can reach Loki, even if an upstream emitter forgot to redact. ## Where it is surfaced **In-app admin audit log.** Two admin endpoints proxy Loki so day-to-day lookups do not require Grafana access: - `GET /api/v1/admin/audit-events`: application-wide, with `from`/`to`/`category`/`action`/ `outcome`/`search` filters. Gated by `Allow.AdminGetAuditEvents`. - `GET /api/v1/admin/users/{userId}/audit-events`: same filters, scoped to one user. Gated by `Allow.AdminGetUserAuditEvents`. Both parse the Loki log lines back into structured fields via `AuditLineParser`, and return a `grafanaUrl` so the SPA can deep-link any single event into Grafana Explore. When Loki is not configured the endpoints return `lokiEnabled: false` and an empty list, and the SPA shows a "Loki not configured" notice instead of an error. See [Adding a permission](/docs/adding-a-permission) for how the `Allow.*` gates are wired. **Grafana.** The provisioned **Slicekit - Audit & Security** dashboard charts failed logins, denials, top actors, and the full audit event log, with a cross-link from each line to its trace. See [Observability](/docs/observability). ## Opting an endpoint out Request auditing is on by default for authenticated calls. To skip a specific endpoint, attach `SkipAuditAttribute`: ```csharp routes.MapGet("/health", HandleAsync) .WithMetadata(new SkipAuditAttribute()); ``` Broader knobs live under `Auditing` in `appsettings.json`: `Enabled` (global on/off), `IncludeAnonymousRequests`, `TrackedEntities`/`ExcludedEntities` for the EF interceptor, `SensitivePropertyNames` for redaction, and `RequestPathExclusions` for the middleware. See the [Settings pattern](/docs/settings-pattern). ## Tamper evidence Every event carries `Sequence`, `PrevHash`, and `Hash` (SHA-256 over canonical JSON including the previous hash). The `audit-events` queue is sequential so the chain links one event at a time. A modified or deleted event shows up as a chain mismatch on the next verification. This is detection-grade, not prevention-grade: anyone who can write to the Loki volume can still delete lines. For write-once regulatory storage, add an S3 Object Lock exporter to the OTel collector, or sign each event with an HMAC key in a separate store. ## Checklist - Inject `IAuditService` only when no domain event already covers the action; otherwise raise the event and let `SecurityAuditEventHandler` emit. - Pick the right `Category`, a dotted `Action` verb, and the correct `Outcome` (use `Failure` for denials so alerts can count them). - Pass `new AuditActor(UserId: actorId)` and let enrichment fill IP hash, UA family, and trace id. - Put structured extras in `Metadata`; never put secrets there unredacted (the patterns in `Auditing:SensitivePropertyNames` are your safety net, not a license). - Leave `Sequence`/`PrevHash`/`Hash` at their defaults. - If you added a new audited admin action, gate its read endpoint with an `Allow.*` permission and confirm it shows up in the admin audit log and the Grafana audit dashboard. - Attach `SkipAuditAttribute` to noisy or health-check endpoints you do not want in the request trail. # Domain and integration events > Publish and handle domain and integration events over Wolverine, with the transactional outbox for reliability. This is the how-to companion to [CQRS and domain events](/docs/cqrs-and-events), which covers the concepts. Here you wire the real thing: define an event, raise it from an aggregate, handle it, and route an integration event out to other services through the transactional outbox. ## Two event types Slicekit distinguishes two kinds of event, each with its own marker, location, and transport. | Type | Marker | Where it lives | Scope | Transport | | ----------------- | ------------------- | ------------------------------------ | ------------- | -------------------------------------- | | Domain event | `IDomainEvent` | `src/Slicekit.Core/Domain/Events/` | In-process | Wolverine local queue | | Integration event | `IIntegrationEvent` | `src/Slicekit.Core/IntegrationEvents/` | Cross-context | RabbitMQ exchange `slicekit.integration` | Both markers are empty interfaces in `src/Slicekit.Core/Domain/Primitives/`. The split is enforced by `EventTaxonomyTests`: an `IDomainEvent` must live under `Domain.Events`, an `IIntegrationEvent` under `IntegrationEvents`, and implementing both is rejected. Keep the verbs straight: aggregates **raise** domain events to record a fact; dispatchers and handlers **publish** events to send them somewhere. They are not interchangeable. ## 1. Define a domain event A domain event is a pure record marked with `IDomainEvent`. No EF Core, no ASP.NET, no Wolverine types: anyone in any layer must be able to deserialize and react. Add it to a file under `src/Slicekit.Core/Domain/Events/` (the existing files group by aggregate, for example `UserEvents.cs`): ```csharp namespace Slicekit.Core.Domain.Events; public sealed record ProjectCreatedEvent(Guid ProjectId, string Name) : IDomainEvent; ``` The `Domain_Events_Must_Not_Depend_On_Infrastructure` test keeps these records clean, so resist the urge to reach for a `DateTimeOffset.UtcNow` default or an injected service inside the record. ## 2. Raise it from the aggregate The aggregate owns its invariants and records what happened by **raising** the event. `AggregateRoot` exposes a protected `Raise` that appends to an internal `Events` list; it sends nothing yet. ```csharp public sealed class Project : AggregateRoot { public static Project Create(Guid ownerId, string name) { var project = new Project { Id = Guid.CreateVersion7(), OwnerId = ownerId, Name = name }; project.Raise(new ProjectCreatedEvent(project.Id, name)); return project; } } ``` `User.Create` in `src/Slicekit.Core/Domain/User.cs` is the canonical example: it raises `UserCreatedEvent` the same way. ### How raised events become published The wiring lives in `src/Slicekit.Core/Configuration/Messaging.cs`: ```csharp opts.UseEntityFrameworkCoreTransactions(); opts.Policies.AutoApplyTransactions(); opts.PublishDomainEventsFromEntityFrameworkCore(x => x.Events); ``` After your slice handler calls `db.SaveChangesAsync`, Wolverine's middleware scrapes each aggregate's `Events` collection and publishes them in-process over a durable local queue. Two consequences: - **Handlers run only if the transaction commits.** Raised events live and die with `SaveChanges`. A rollback drops them. - **`AutoApplyTransactions()` is load-bearing.** Without it the scraper is registered but never engages, and raised events are silently discarded. If a handler that looks correct never fires, check this line first. ## 3. Handle the event Handlers are discovered by convention: a `*Handler` class with a `Handle` method whose first parameter is the event type. No interface, no registration. Place the handler inside the slice that owns the reaction, alongside its command and handler. ```csharp using Slicekit.Core.Domain.Events; using Wolverine; namespace Slicekit.Core.Features.Projects.Welcome; public sealed class NotifyProjectCreatedHandler(IMessageBus bus) { public ValueTask Handle(ProjectCreatedEvent evt) => bus.PublishAsync(new SendTemplateEmailCommand { Recipient = /* ... */, Subject = "Your project is ready", Template = EmailTemplate.Welcome, Model = /* ... */ }); } ``` Many handlers can react to one event; they fan out independently. Real examples both react to `UserCreatedEvent`: `Features/Auth/Welcome/SendWelcomeEmailHandler.cs` sends a welcome email, and `Auditing/SecurityAuditEventHandler.cs` writes an audit row. A handler may take a `CancellationToken` and inject services as extra parameters, exactly like a command handler. ## 4. Route an integration event through the outbox An integration event is the public, stable contract that other services consume. It is published to the `slicekit.integration` RabbitMQ exchange. The template ships with none: `IntegrationEvents/` is empty until your application defines its first cross-context contract. Define it under `src/Slicekit.Core/IntegrationEvents/`, using only primitives, strings, and enums: ```csharp namespace Slicekit.Core.IntegrationEvents; public sealed record OrderPlacedIntegrationEvent( Guid OrderId, Guid CustomerId, decimal Total, DateTimeOffset PlacedAtUtc) : IIntegrationEvent; ``` `Integration_Events_Must_Not_Depend_On_Internal_Types` blocks these from referencing `Domain.Events`, `Identity`, `Features`, EF Core, or Wolverine. The contract must stay decoupled from your internals. **Do not publish an integration event from an aggregate.** The aggregate raises a domain event; a separate translator handler turns that into the public contract: ```csharp // Features/Orders/PublishOrderPlacedIntegrationEvent/Handler.cs public sealed class PublishOrderPlacedIntegrationEventHandler(IMessageBus bus) { public Task Handle(OrderPlacedEvent evt, CancellationToken ct) => bus.PublishAsync(new OrderPlacedIntegrationEvent( evt.OrderId, evt.CustomerId, evt.Total, DateTimeOffset.UtcNow)); } ``` The routing is already configured in `Messaging.cs`: ```csharp opts.Publish(x => x.MessagesImplementing().ToRabbitExchange("slicekit.integration")); ``` Outside the testing environment Wolverine persists outgoing messages with Postgres (`PersistMessagesWithPostgresql`) and uses durable local queues. That is the transactional outbox: the integration event is written in the same transaction as your state change and relayed to RabbitMQ once the transaction commits. If the process dies mid-flight, the relay resumes, so delivery is at-least-once. Write consumers to be idempotent. ## Events are not commands A domain event records a fact and fans out to any number of handlers. A Wolverine command (`SendTemplateEmailCommand`, `CleanupExpiredTokensCommand`) targets exactly one handler and triggers a specific action; it implements neither marker. Reach for a command when the producer has a single receiver in mind, when the work must run after the transaction commits without influencing its outcome, or when you want Wolverine's retry semantics on a step that might fail. Commands ride the same outbox. ## Verify ```sh dotnet test api/tests/Slicekit.Architecture.Tests --nologo ``` - `EventTaxonomyTests` and `LayerDependencyTests` pass. - A new event in the wrong namespace fails `EventTaxonomyTests`. - An integration event leaking a domain type fails `Integration_Events_Must_Not_Depend_On_Internal_Types`. ## Checklist - [ ] Domain event is a pure record marked `IDomainEvent`, under `Domain/Events/`. - [ ] The aggregate **raises** the event; nothing publishes from inside the aggregate. - [ ] A `*Handler` with a `Handle(TEvent, ...)` method lives in the reacting slice. - [ ] Cross-context contracts are `IIntegrationEvent` records under `IntegrationEvents/`, primitives only. - [ ] A translator handler maps the domain event to the integration event; the outbox carries it to RabbitMQ. - [ ] `dotnet test api/tests/Slicekit.Architecture.Tests --nologo` is green. # Error handling > The Result and AppError taxonomy, how failures map to ProblemDetails responses, and how to add an error type. ## Two values, never an exception Slicekit handlers do not throw to signal a business failure. They return a `Result`: either a value-bearing success or an `AppError`. Endpoints translate that error into an RFC 7807 `ProblemDetails` response with a single call to `error.ToProblem()`. The mapping is centralised: handlers never construct HTTP status codes, and endpoints never assemble ad-hoc problem responses. The whole contract is two small types in `api/src/Slicekit.Core/Common/`: - `Result.cs`: `Result` and the non-generic `Result`. - `AppError.cs`: the abstract `AppError` record and its concrete subtypes. ## The `Result` type `Result` is a `readonly struct` (no allocation) that holds either a `T` or an `AppError`. You rarely construct it explicitly because of two implicit conversions: ```csharp public async Task HandleAsync(GetUserQuery query, CancellationToken ct) { var user = await db.Users.FindAsync([query.UserId], ct); if (user is null) return new NotFoundError("User"); // AppError -> Result return new UserDto(user.Id, user.Email); // T -> Result } ``` For commands with no value-bearing success, return the non-generic `Result` (`Result.Ok()` or an error). For composing several fallible steps without exception flow, `Map`, `MapAsync`, `Bind`, and `BindAsync` chain over the success branch and short-circuit on the first error: ```csharp return await GetProject(id) .BindAsync(p => Rename(p, request.Name)) .MapAsync(p => new Response(p.Id, p.Name)); ``` `IsSuccess` / `IsFailure` gate access: reading `.Value` on a failure (or `.Error` on a success) throws, so always branch on `IsSuccess` first. ## The `AppError` taxonomy Every error is a subtype of `AppError(string Message, string? ErrorCode)`. The full set lives in `api/src/Slicekit.Core/Common/AppError.cs` and maps to status codes in `api/src/Slicekit.Api/Errors/AppErrorExtensions.cs`: | `AppError` subtype | Status | `ErrorCode` | When to use | | --------------------------------- | ------ | ------------------------ | ------------------------------------------------------------ | | `NotFoundError(resource)` | 404 | `NotFound` | Resource does not exist, or the caller has no view of it. | | `ConflictError(resource, detail)` | 409 | `Conflict` (overridable) | Conflicting state: duplicate, optimistic concurrency. | | `ForbiddenError(permission)` | 403 | `Forbidden` | Authenticated caller lacks a permission. | | `UnverifiedEmailError()` | 403 | `EmailNotVerified` | Action requires a verified email address. | | `UnauthorizedError()` | 401 | `Unauthorized` | Caller is not authenticated. | | `ValidationError(errors)` | 400 | `ValidationError` | Business-rule validation inside the handler (see below). | | `LockedError(unlockedAt)` | 423 | `AccountLocked` | Identity lockout; carries the unlock timestamp. | | `TotpRequiredError(pendingToken)` | 403 | `TotpRequired` | First factor passed, 2FA still required. | | `TotpInvalidError()` | 400 | `TotpCodeInvalid` | Submitted TOTP code is wrong or expired. | | `TotpSetupRequiredError()` | 403 | `TotpSetupRequired` | 2FA enrolment is required before continuing. | | `PasskeyVerificationFailedError` | 400 | `PasskeyVerificationFailed` | WebAuthn assertion did not verify. | | `PasskeyCeremonyExpiredError()` | 410 | `PasskeyCeremonyExpired` | Passkey challenge expired; restart the ceremony. | | `InvalidTokenError()` | 422 | `InvalidToken` | A link or token is invalid or has expired. | | `InternalError(detail)` | 500 | `InternalError` | Fallback. Prefer letting unexpected failures bubble. | Most slices only ever need `NotFoundError`, `ConflictError`, and `ValidationError`. Reach for the auth-specific subtypes only inside the authentication flows. See [Authentication](/docs/authentication) for where those are raised. ## Mapping to `ProblemDetails` `AppErrorExtensions.ToProblem` is a single `switch` over the error type. Each arm builds a `ProblemHttpResult` with a status, a title, and an `extensions` dictionary. Every arm emits `errorCode`; some add extra fields: ```csharp public static ProblemHttpResult ToProblem(this AppError error) => error switch { NotFoundError e => TypedResults.Problem(e.Message, statusCode: 404, title: "Not Found", extensions: CodeExt(e)), LockedError e => TypedResults.Problem(e.Message, statusCode: 423, title: "Locked", extensions: new() { ["errorCode"] = e.ErrorCode, ["unlockedAt"] = e.UnlockedAt }), ValidationError e => TypedResults.Problem(e.Message, statusCode: 400, title: "Validation Error", extensions: new() { ["errorCode"] = e.ErrorCode, ["errors"] = e.Errors }), // ... _ => TypedResults.Problem("An unexpected error occurred.", statusCode: 500, title: "Internal Server Error") }; ``` The `_` fallthrough is the trap: an `AppError` subtype with no `switch` arm compiles fine but returns an opaque 500 with no `errorCode`. Adding a subtype always means adding an arm. ## The endpoint contract Endpoints stay thin (see [Adding a vertical slice](/docs/vertical-slices)). They invoke the handler over Wolverine and map the result. For the common shapes there are extension shortcuts on `Result`: ```csharp private static async Task> HandleAsync( Guid id, IMessageBus bus, CancellationToken ct) { var result = await bus.InvokeAsync(new GetUserQuery(id), ct); return result.ToOkOrProblem(); } ``` ```csharp return result.ToCreatedOrProblem($"/users/{result.Value.Id}"); // Results return result.ToNoContentOrProblem(); // on the non-generic Result ``` Declare every status code the endpoint can return so OpenAPI stays the source of truth for clients: ```csharp .Produces() .ProducesProblem(400) // validation .ProducesProblem(403) // permission filter .ProducesProblem(404); // not-found from the handler ``` ## Two validation surfaces, kept apart Slicekit validates in two distinct places, and mixing them is the most common mistake: 1. **Request shape, at the filter layer.** `ValidationEndpointFilter` runs FluentValidation before the handler and returns a 400 with the standard `errors` dictionary. This is where `RuleFor(x => x.Email).EmailAddress()` lives. 2. **Domain rules, inside the handler.** Return `new ValidationError(errors)` as a `Result` failure when the rule needs domain state: "cannot delete the last admin", "email is taken". Do not express domain rules in FluentValidation, and do not re-check request shape in the handler. ## Unhandled exceptions `GlobalExceptionHandler` is the net under everything that escapes a handler: - `UnauthorizedAccessException` becomes 401. - `FluentValidation.ValidationException` becomes 400 with a validation problem. - Anything else becomes 500, with the stack trace logged and the trace id surfaced on the response. Do not catch and rethrow inside a handler to "improve" a message. Return an `AppError` if you recognise the case, otherwise let the exception bubble. ## How the frontend surfaces it The API client reads the `ProblemDetails` body into an `AppError` class (`frontend/src/shared/api/errors.ts`). Two behaviours matter: - **Auto-translation.** The constructor takes `errorCode`, snake-cases it, and looks up `errors.` in the active locale. If a translation exists it overwrites `detail`, so a plain `toast.error(err.detail)` renders the localised message for free. - **Form binding.** `applyToForm(setError)` walks the `errors` dictionary and pushes per-field messages into react-hook-form, using `validationParams` as interpolation values. That is why a new error code needs i18n keys on both sides. The wiring is detailed in [The API client](/docs/api-client). ## Adding a new error type Say you need a `PaymentFailedError`. Four edits, all small: 1. **Declare the subtype** in `AppError.cs` with a stable code: ```csharp public sealed record PaymentFailedError(string Reason) : AppError($"Payment failed: {Reason}", "PaymentFailed"); ``` 2. **Add a `switch` arm** in `AppErrorExtensions.ToProblem` (skip this and it falls through to 500): ```csharp PaymentFailedError e => TypedResults.Problem(e.Message, statusCode: 402, title: "Payment Required", extensions: CodeExt(e)), ``` 3. **Return it** from the handler as you would any other `AppError`: ```csharp if (!charge.Succeeded) return new PaymentFailedError(charge.FailureReason); ``` 4. **Add the i18n key** (camelCase of the code) to both `frontend/src/shared/i18n/locales/en.json` and `nl.json`: ```json "errors": { "paymentFailed": "Payment could not be processed." } ``` For a custom FluentValidation rule (any `.Must(...)`, `.Matches(...)`, or hand-written validator), chain `.WithErrorCode("MyCode")` after `.WithMessage(...)`, map the code in `frontend/src/shared/i18n/errorCodeMap.ts`, and add the matching `validation.*` key to both locales. ## Checklist - [ ] Handler returns `Result` (or `Result`), never throws for a business failure. - [ ] Failure cases return a fitting `AppError` subtype; reuse before adding a new one. - [ ] New subtype carries a stable `ErrorCode` and has a `switch` arm in `AppErrorExtensions.ToProblem`. - [ ] Endpoint maps with `ToOkOrProblem` / `ToCreatedOrProblem` / `ToNoContentOrProblem` and declares every `.ProducesProblem(...)`. - [ ] Request-shape rules live in FluentValidation; domain rules return `ValidationError` from the handler. - [ ] New error code has `errors.` keys in both `en.json` and `nl.json`. - [ ] `dotnet test api/tests/Slicekit.Unit.Tests` and `cd frontend && pnpm test` both pass. # File storage > Upload, store and serve files through the S3-compatible storage abstraction (MinIO locally). ## One seam, two backends File storage hides behind a single interface, `IFileStorage`. The implementation talks to an S3-compatible bucket: real **AWS S3** in production, **MinIO** on your machine. The same `AWSSDK.S3` client backs both, so only configuration changes between environments, never your code. ``` api/src/Slicekit.Core/ Domain/Services/ IFileStorage.cs // the seam: interface plus the record types Infrastructure/Storage/ S3FileStorage.cs // S3-backed implementation, internal StorageSettings.cs // bound config, validated at startup ``` `IFileStorage` lives in `Slicekit.Core/Domain/Services/IFileStorage.cs`. The implementation is `internal`, so handlers depend only on the interface. No HTTP endpoint ships: the upload shape is application-specific, so you wire your own slice (see [Adding a vertical slice](/docs/vertical-slices)). ## The interface Every method returns `Result` (or `Result` for `DeleteAsync`), so failures arrive as `AppError` rather than exceptions. The shared `ToProblem()` mapping turns those into ProblemDetails when you surface them from an endpoint. ```csharp namespace Slicekit.Core.Domain.Services; public interface IFileStorage { Task PutAsync(FileUpload file, CancellationToken ct = default); Task> PutManyAsync( IReadOnlyList files, CancellationToken ct = default); Task GetAsync(string key, CancellationToken ct = default); Task DeleteAsync(string key, CancellationToken ct = default); Task GetPresignedUrlAsync( string key, TimeSpan? expiresIn = null, CancellationToken ct = default); } ``` The record types it trades in: ```csharp public sealed record FileUpload(string FileName, string ContentType, long Size, Stream Content); public sealed record StoredFile(string Key, string ContentType, long Size); public sealed record FileContent(Stream Content, string ContentType, long Size) : IAsyncDisposable; ``` | Method | Returns | Notes | | ---------------------------------------- | ----------------------------------- | ----------------------------------------------------------------- | | `PutAsync(FileUpload)` | `Result` | Validates size and content type. Failures: `ValidationError`, `InternalError`. | | `PutManyAsync(IReadOnlyList)`| `IReadOnlyList` | One result per input, same order. One bad file does not fail the others. | | `GetAsync(key)` | `Result` | `NotFoundError` when the key is missing. `FileContent` is `IAsyncDisposable`. | | `DeleteAsync(key)` | `Result` | Idempotent at the S3 layer: missing keys still succeed. | | `GetPresignedUrlAsync(key, expiresIn?)` | `Result` | Time-limited GET URL. Defaults to `PresignedUrlDefaultMinutes`. | ## Uploading from a handler Inject `IFileStorage` and call `PutAsync`. A single upload is one line: ```csharp public sealed class UploadAvatarHandler(IFileStorage files) { public async Task HandleAsync(FileUpload upload, CancellationToken ct) => await files.PutAsync(upload, ct); } ``` Batch uploads use `PutManyAsync`. The batch never aborts: each input gets its own `Result` in input order, so one oversize file does not sink the rest. ```csharp public sealed class UploadAttachmentsHandler(IFileStorage files) { public async Task> HandleAsync( IReadOnlyList uploads, CancellationToken ct) => await files.PutManyAsync(uploads, ct); } ``` Failures come back as `AppError`: `ValidationError` for an oversize or disallowed content type, `InternalError` for a storage exception. Map them with `result.Error.ToProblem()` in your endpoint (see [Result and error handling](/docs/error-handling)). ### Keys are generated, never chosen You do not pass a key into `PutAsync`. The storage layer generates one server-side as `yyyy/MM/dd/.` and returns it on `StoredFile.Key`. The original filename is preserved in S3 metadata under `original-filename` (URL-encoded). Generating the key avoids reflecting untrusted input into the object path and gives you prefix-listable date partitions for free. Persist the returned key against your domain row; that is the handle you store and download by later. ## Serving and downloading There are two ways to get bytes back out, and the choice is about who streams them. Stream through your API when you want to gate the download behind a permission check. `GetAsync` returns a `FileContent`, which is `IAsyncDisposable`, so `await using` it while you stream: ```csharp public sealed class ServeAttachmentHandler(IFileStorage files) { public async Task HandleAsync(string key, CancellationToken ct) => await files.GetAsync(key, ct); // NotFoundError when the key is missing } ``` Hand out a presigned URL when the bytes do not need to pass through your server. The browser then fetches the object straight from S3, with no server round-trip for the payload: ```csharp var result = await files.GetPresignedUrlAsync(key); // default expiry var result = await files.GetPresignedUrlAsync(key, TimeSpan.FromHours(1)); // explicit expiry ``` Deleting is idempotent: a missing key still succeeds, so you can call it without a prior existence check. ```csharp var result = await files.DeleteAsync(key, ct); ``` ### Accepting the upload over HTTP No upload UI ships in the template. When you add an endpoint that builds a `FileUpload` from an `IFormFile`, the matching frontend slice POSTs `multipart/form-data` through the `apiFetch` wrapper. Pass the `FormData` through unmodified: do not set `Content-Type` yourself, because the browser fills in the multipart boundary for you. ## Configuration Settings bind from the `Storage:` section and validate at startup via `ValidateOnStart()`. Every key has a matching env-var override (see [Configuration](/docs/configuration)). | Setting | Env var | Default | Notes | | ------------------------------------ | -------------------------------------- | ------------------ | ----------------------------------------------------------- | | `Storage:Bucket` | `Storage__Bucket` | _required_ | Bucket the SDK targets. | | `Storage:Region` | `Storage__Region` | `us-east-1` | Real AWS S3 region. MinIO ignores it, but a value is needed. | | `Storage:ServiceUrl` | `Storage__ServiceUrl` | _empty_ | Empty means real AWS S3. Set it for MinIO or another S3-compatible host. | | `Storage:AccessKey` | `Storage__AccessKey` | _empty_ | Empty plus real AWS uses the default credential chain. | | `Storage:SecretKey` | `Storage__SecretKey` | _empty_ | | | `Storage:MaxFileSizeBytes` | `Storage__MaxFileSizeBytes` | `26214400` (25 MB) | Per-file cap. Oversize files come back as `ValidationError`. | | `Storage:MaxFilesPerRequest` | `Storage__MaxFilesPerRequest` | `20` | Soft cap surfaced to callers. Enforce it at your endpoint. | | `Storage:AllowedContentTypes` | `Storage__AllowedContentTypes__0`, etc.| `[]` | Empty allows any content type. Otherwise an allow-list. | | `Storage:PresignedUrlDefaultMinutes` | `Storage__PresignedUrlDefaultMinutes` | `15` | Default expiry when `GetPresignedUrlAsync` is called without an explicit window. | A few rules the DI layer enforces so operators only ever set the URL: - `ServiceUrl` and `ForcePathStyle` are coupled. When a `ServiceUrl` is configured the client flips to path-style addressing automatically, and switches to `http://` when the URL is plaintext. - Cross-field validation (`StorageSettings.Validate`): when `ServiceUrl` is set, both `AccessKey` and `SecretKey` are required. The default AWS credential chain is only reachable when `ServiceUrl` is empty. ## Local development with MinIO `docker compose up -d` brings up MinIO (port `9000` for the S3 API, `9001` for the console) plus a one-shot `minio-bootstrap` container that creates the `slicekit-dev` bucket. `appsettings.json` already points at MinIO with `minioadmin/minioadmin`, so a fresh checkout works with no env vars. See [Getting started](/docs/getting-started) for the full first-run flow. ```sh docker compose up -d minio minio-bootstrap ``` Open the console at and sign in with `minioadmin` / `minioadmin` to watch objects land under `slicekit-dev/yyyy/MM/dd/.`. ## Production Fill the `STORAGE_*` block in `.env.prod`. Three common shapes: - **Real AWS S3 with an IAM role** (recommended): leave `STORAGE_SERVICE_URL`, `STORAGE_ACCESS_KEY` and `STORAGE_SECRET_KEY` blank. The SDK uses the default credential chain (IAM role, environment, `~/.aws`). - **Real AWS S3 with static keys**: leave `STORAGE_SERVICE_URL` blank, set `STORAGE_ACCESS_KEY` and `STORAGE_SECRET_KEY`. - **Self-hosted MinIO or another S3-compatible host**: set all three. ## Checklist - [ ] `docker compose up -d minio minio-bootstrap` brings MinIO up and creates the `slicekit-dev` bucket. - [ ] Inject `IFileStorage` into your handler and call `PutAsync` / `PutManyAsync`. - [ ] Persist the returned `StoredFile.Key` against your domain row. - [ ] Serve bytes with `GetAsync` (gated) or hand out a `GetPresignedUrlAsync` URL (direct). - [ ] Map `result.Error.ToProblem()` for the `ValidationError` and `NotFoundError` cases. - [ ] Confirm the object under `slicekit-dev/` in the MinIO console at . # Adding an API version > Introduce a new API version (v2) without breaking existing clients. ## How versioning is wired Every route in Slicekit lives under `/api/{version}`. The version segment is not a string scattered across endpoints: it comes from a single registry, and each version owns a folder of endpoint classes that are discovered at compile time. Adding `v2` means registering one more version, pointing the source generator at a new folder, and writing the endpoints. The `v1` surface keeps working untouched, because nothing about it changes. The pieces involved, all in `api/src/Slicekit.Api`: ``` Configuration/Versioning.cs // the list of known versions Endpoints/RegisterEndpoints.cs // scans folders, maps each version group Endpoints/Groups.cs // route group helpers: Users(), Auth(), ... Endpoints/v1/ // the existing version's endpoint classes ``` Endpoints themselves are thin HTTP adapters. The command, handler and validation behind them live in `Slicekit.Core` and are shared across versions, so a new version is a new shape over the same business logic. See [Adding a vertical slice](/docs/vertical-slices) for that split, and [Architecture](/docs/architecture) for the wider picture. ## 1. Register the version `ApiVersions` is the single source of truth. Add the new constant and put it in `All`, in `api/src/Slicekit.Api/Configuration/Versioning.cs`: ```csharp namespace Slicekit.Api.Configuration; internal static class ApiVersions { public const string V1 = "v1"; public const string V2 = "v2"; public static readonly string[] All = [V1, V2]; } ``` `All` drives OpenAPI document generation, so a Swagger document for `v2` appears automatically. Endpoint registration is still explicit (step 2): listing the version is not the same as mapping its routes. ## 2. Scan and map the new folder Endpoints are not registered by hand. `ServiceScan.SourceGenerator` finds every `IEndpoint` whose type name matches a filter and emits the `Map...` method at compile time. Add a `[ScanForTypes]` attribute plus a matching partial method to `api/src/Slicekit.Api/Endpoints/RegisterEndpoints.cs`, mirroring the `v1` block: ```csharp [ScanForTypes(AssignableTo = typeof(IEndpoint), Handler = "Map", TypeNameFilter = "Slicekit.Api.Endpoints.V2.*")] private static partial IEndpointRouteBuilder MapV2Endpoints(this IEndpointRouteBuilder routes); ``` Then mount it under a version group in `MapApiEndpoints`, next to the existing `v1` wiring: ```csharp var v2 = app.VersionGroup(ApiVersions.V2); v2.MapV2Endpoints(); ``` `VersionGroup` is the private helper that prefixes `/api/{version}`, requires authorization, applies the default rate limiter and the TOTP-setup filter, and advertises a 401 response. Every version inherits that baseline, so a `v2` route is secured the same way a `v1` route is. ## 3. Create the endpoint classes Add `api/src/Slicekit.Api/Endpoints/v2/` and place one class per endpoint, grouped into subfolders by domain (`v2/Me/`, `v2/Admin/`, and so on) exactly as `v1` is laid out. The namespace must start with `Slicekit.Api.Endpoints.V2` so the scan filter from step 2 picks it up. Each class implements `IEndpoint`, declares its route group with an extension from `Groups.cs`, and dispatches to a `Slicekit.Core` handler over Wolverine's message bus: ```csharp using Slicekit.Core.Features.Users.GetMe; using Slicekit.Core.Permissions; namespace Slicekit.Api.Endpoints.V2.Me; internal sealed class GetMeEndpoint : IEndpoint { public static void Map(IEndpointRouteBuilder routes) => routes.Users().MapGet("/me", HandleAsync) .WithName("V2_Users_GetMe") .WithSummary("Get current user") .WithTags("Users") .RequirePermission(Allow.UserGetMe) .AllowWithoutTotpSetup() .Produces() .ProducesProblem(403); private static async Task> HandleAsync( ClaimsPrincipal principal, IMessageBus bus, CancellationToken ct) { var userId = principal.TryGetUserId() ?? throw new UnauthorizedAccessException("User ID claim not found or invalid."); var result = await bus.InvokeAsync( new GetMeQuery(userId, principal.TryGetActingUserId()), ct); if (!result.IsSuccess) return result.Error.ToProblem(); var r = result.Value; return TypedResults.Ok(new Response(r.Id, r.Email, r.DisplayName)); } public sealed record Response(Guid Id, string Email, string? DisplayName); } ``` Two conventions matter here: - **Give the endpoint a version-scoped name.** `WithName` must be unique across the whole app, so prefix it with the version (`V2_Users_GetMe`). The `v1` class keeps its own `Users_GetMe`. - **Reuse the handler, change only the shape.** The example above returns a leaner `Response` than `v1` does, but it sends the same `GetMeQuery`. New versions are where you reshape requests and responses; the logic in `Slicekit.Core` stays shared. If a version needs genuinely new behavior, add a new command and handler as a [vertical slice](/docs/vertical-slices) rather than branching on the version inside a handler. ## 4. Add a route group if needed The route group helpers live in `api/src/Slicekit.Api/Endpoints/Groups.cs` as extension methods on `IEndpointRouteBuilder`: ```csharp internal static class ApiGroups { extension(IEndpointRouteBuilder routes) { public RouteGroupBuilder Auth() => routes.MapGroup("/auth"); public RouteGroupBuilder Users() => routes.MapGroup("/users"); public RouteGroupBuilder ApiKeys() => routes.MapGroup("/api-keys"); public RouteGroupBuilder Admin() => routes.MapGroup("/admin"); } } ``` These are version-agnostic: `routes.Users()` resolves to `/api/v1/users` or `/api/v2/users` depending on the version group the endpoint is mapped under. Reuse the existing groups. Only add a new one when `v2` introduces a domain that does not exist yet, for example: ```csharp public RouteGroupBuilder Payments() => routes.MapGroup("/payments"); ``` ## Keeping v1 working Nothing in steps 1 through 4 edits the `v1` folder, the `v1` scan attribute, or `Users_GetMe`. That is the point: `v1` clients keep hitting `/api/v1/...` against unchanged endpoints while `v2` is built alongside. A few rules keep it that way: - **Never reshape a `v1` response in place.** Changing the JSON a `v1` endpoint returns is a breaking change even though the route is identical. Reshape in `v2` instead. - **Share handlers, fork endpoints.** Edits to a `Slicekit.Core` handler reach every version. If a change would alter what `v1` returns, branch it into a new slice rather than mutating the shared handler. - **The frontend pins its version.** The SPA hard-codes the version in each `apiFetch('/api/v1/...')` call. Shipping `v2` does not move the frontend automatically. Migrate it slice by slice in `frontend/src/features//api.ts` when each `v2` endpoint is ready. See the [API client](/docs/api-client) for how those calls are structured. ## Checklist - [ ] Added the version constant and entry in `Configuration/Versioning.cs` (`ApiVersions.All`). - [ ] Added a `[ScanForTypes]` attribute and partial `MapV2Endpoints` method in `RegisterEndpoints.cs`. - [ ] Mounted the version group with `app.VersionGroup(ApiVersions.V2).MapV2Endpoints()`. - [ ] Created `Endpoints/v2/` with endpoint classes under the `Slicekit.Api.Endpoints.V2` namespace. - [ ] Gave every endpoint a version-prefixed `WithName` (`V2_...`). - [ ] Reused `Slicekit.Core` handlers; reshaped only the request and response records. - [ ] Added new route groups in `Groups.cs` only for genuinely new domains. - [ ] Left the `v1` folder, scan filters, and response shapes untouched. - [ ] Migrated frontend calls in `frontend/src/features//api.ts` as each `v2` endpoint lands. # Data export & GDPR > Export a user's personal data and handle deletion, the GDPR-oriented personal-data tooling. ## Two obligations, two slices Every record keyed by a user is subject to two GDPR rights, and Slicekit wires each to its own vertical slice: - **Article 15 (right of access)**: `GET /api/v1/users/me/export` returns everything the system holds about the caller as a single JSON document. The slice lives in `api/src/Slicekit.Core/Features/Users/ExportMyData/`. - **Article 17 (right to erasure)**: deleting an account removes or anonymises every user-owned row. The slice lives in `api/src/Slicekit.Core/Features/Users/DeleteUser/`. The two are deliberately symmetric. Any new entity you add that references a user must be reflected in **both** handlers in lockstep. Forgetting either side is a compliance defect, and an architecture test fails the build to make sure you don't (see [The guardrail](#the-guardrail) below). ## What counts as personal data If your feature stores rows keyed by `UserId`, directly via a foreign key or transitively via a join through another user-owned table, it is almost certainly personal data. Consents, refresh-token sessions, API keys, passkeys, and permission grants all qualify and are already handled. When you're unsure, grep the EF configurations: ```sh rg "UserId" api/src/Slicekit.Core/Persistence/Configurations/ ``` Each result is a candidate that needs a place in the export and a deletion strategy. ## How the export is produced The endpoint is a thin adapter in `api/src/Slicekit.Api/Endpoints/v1/Me/ExportMeEndpoint.cs`. It maps `GET /me/export`, requires the `UserExportMyData` permission, rate-limits the call, sets a `Content-Disposition: attachment` header so browsers download a `.json` file, and dispatches an `ExportMyDataQuery` over the Wolverine bus: ```csharp routes.Users().MapGet("/me/export", HandleAsync) .RequirePermission(Allow.UserExportMyData) .RequireRateLimiting(RateLimitPolicies.ExportData) .Produces(); ``` All the work happens in `ExportMyDataQueryHandler`. It loads the Identity row and each user-owned table, projects the rows into `Exported*` records, and emits an audit event before returning: ```csharp var consents = await db.UserConsents .Where(c => c.UserId == appUser.Id) .OrderBy(c => c.GrantedAtUtc) .Select(c => new ExportedConsent(c.Id, c.ConsentType, c.Version, c.GrantedAtUtc)) .ToListAsync(ct); // ...sessions, apiKeys, passkeys, permissions, permissionExclusions... return new ExportMyDataResult( SchemaVersion: 2, ExportedAt: DateTimeOffset.UtcNow, User: new ExportedUser(/* id, email, displayName, phone, flags, timestamps */), Consents: consents, Sessions: sessions, ApiKeys: apiKeys, Passkeys: passkeys, Permissions: permissions, PermissionExclusions: permissionExclusions); ``` The shape is the `ExportMyDataResult` record in `Features/Users/ExportMyData/Query.cs`. It serialises to a stable JSON document: ```json { "schemaVersion": 2, "exportedAt": "2026-06-13T12:34:56Z", "user": { ... }, "consents": [ ... ], "sessions": [ ... ], "apiKeys": [ ... ], "passkeys": [ ... ], "permissions": [ ... ], "permissionExclusions": [ ... ] } ``` `SchemaVersion` is part of the contract. Once a downstream consumer relies on a key, renaming or removing it is a breaking change: bump `SchemaVersion` and call it out in the PR. ### Extend the export when you add a feature In `ExportMyDataQueryHandler.HandleAsync`, add a query for your data, project it into a new `Exported` record (defined alongside the others in `Query.cs`), and surface it on `ExportMyDataResult`. Filter every join to the requesting user so peer data never leaks. ### What never goes in the export The export is meant for the user, so it must never carry security material an attacker could use: | Field | Why | | -------------------------------------------------- | ------------------------------------------------------------------ | | Password hash (`ApplicationUser.PasswordHash`) | Excluded by Identity convention; do not re-add. | | Refresh-token hashes | A hash is useless to the user and useful to an attacker. | | API-key material beyond `KeyHint` | Export the hint and metadata only, never the hash or plaintext. | | `SecurityStamp` / `ConcurrencyStamp` | Identity-internal columns with no user-facing meaning. | | Audit chain hashes / sequence pointers | The user's audit events are exportable; the chain structure isn't. | | Other users' identifiers in shared resources | Filter joins to the requesting user; redact peer identifiers. | ### Rate limiting and auditing The endpoint uses the `ExportData` rate-limit policy (per user) so the heavy query can't be hammered. Every successful export emits an `AuditCategory.DataAccess` event with action `Me.Export`, a chain-linked record that the user themselves requested their data. See [Auditing](/docs/auditing) for how those events are chained and stored, and [Rate limiting](/docs/rate-limiting) for the policy definitions. ## How erasure is handled Deletion runs through `DeleteUserCommandHandler` in `Features/Users/DeleteUser/Handler.cs`. It hard-deletes the user's owned tables with `ExecuteDeleteAsync`, then anonymises the Identity row in place: the row stays so foreign keys remain valid, but every PII field is cleared. ```csharp await db.RefreshTokens.Where(r => r.UserId == appUser.Id).ExecuteDeleteAsync(ct); await db.ApiKeys.Where(k => k.UserId == appUser.Id).ExecuteDeleteAsync(ct); await db.Set().Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct); await db.UserPermissions.Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct); // Clear PII on the Identity row, keep the row for referential integrity. appUser.Email = null; appUser.NormalizedEmail = null; appUser.PhoneNumber = null; appUser.PasswordHash = null; appUser.SecurityStamp = Guid.NewGuid().ToString(); await userManager.UpdateAsync(appUser); domainUser.MarkDeleted(command.ActorId, originalEmail); domainUser.Anonymize(); await db.SaveChangesAsync(ct); ``` When you add user-owned data, pick one of three strategies for it: - **Hard delete**: add a `db..Where(x => x.UserId == appUser.Id).ExecuteDeleteAsync(ct)`. Use for anything with no value once the user is gone. This is the default and matches the existing rows above. - **Anonymise in place**: clear the PII fields, keep the row. Use when a downstream aggregate (the audit trail, analytics) needs referential continuity. This is what the Identity and domain `User` rows do. - **Retain unchanged**: exempt the entity entirely. Use only when no PII remains on the row once the linked user is anonymised **and** retention is legally required (for example GDPR Article 7(1), "demonstrate consent"). `UserConsent` is the precedent: it keeps only `ConsentType`, `Version`, and the granted/withdrawn timestamps, non-PII bookkeeping pointed at an already-anonymised user. Match the handler's existing style: `ExecuteDeleteAsync` for owned tables, mutate-then-`UpdateAsync` for the Identity row. ## The guardrail `api/tests/Slicekit.Architecture.Tests/PersonalDataCompletenessTests.cs` is the safety net. It enumerates every type under `Slicekit.Core.Domain` that has a `Guid UserId` property and asserts each one is referenced by both `ExportMyDataQueryHandler` (the Article 15 check) and `DeleteUserCommandHandler` (the Article 17 check). Add a user-owned entity without touching both handlers and the build goes red. Two exemption lists let you opt out per side, each with an inline justification: - `ExportExemptions`: rare; needs an Article 15 rationale. - `DeleteExemptions`: for entities that must legally be retained (the `UserConsent` precedent above). ## Tests for new data For every branch you add to either slice, add a feature test: - `tests/Slicekit.Feature.Tests/Features/Users/ExportMyData/ExportTests.cs`: assert the data appears in the export and that the secrets-exclusion list is honoured. - `tests/Slicekit.Feature.Tests/Features/Users/DeleteUser/DeletionTests.cs`: assert the data is gone or anonymised after the handler runs. Run them with: ```sh dotnet test api/tests/Slicekit.Feature.Tests --nologo \ --filter "FullyQualifiedName~ExportMyData|FullyQualifiedName~DeleteUser" dotnet test api/tests/Slicekit.Architecture.Tests --nologo ``` ## Checklist When a feature touches user data, before you merge: 1. List every table your feature owns or extends that references a user, directly or via a join. 2. Add a query to `ExportMyDataQueryHandler` and a new `Exported` record on `ExportMyDataResult`. 3. Pick a deletion strategy (hard delete, anonymise, or retain) and wire it into `DeleteUserCommandHandler`. 4. Keep secrets out of the export: no hashes, key material, Identity stamps, or peer identifiers. 5. Bump `SchemaVersion` if the export shape breaks consumers, and note it in the PR. 6. Add the matching `ExportMyData` and `DeleteUser` feature tests. 7. Confirm `PersonalDataCompletenessTests` passes, or add a justified exemption. # Impersonation > Let admins safely impersonate a user for support, with the audit trail that records it. ## What impersonation gives you A holder of `Admin.ImpersonateUser` can act in the app **as another user** for a short, capped window, while every action they take is still attributed to them in the audit log. It is support tooling: reproduce a bug a customer reports, check what they actually see, fix it, and step back out, all without ever knowing their password. The trick is a layered JWT. The **target user** drives the session (permissions, `GET /me`, everything the app reads from `sub`), while the **acting admin** rides along in an extra `act` claim that exists only for audit attribution and the stop gate. ``` sub = target user // drives permissions, JWT validation, GetMe act = admin user // audit attribution + "stop impersonating" gate ``` Because `sub` is the target, the rest of the system needs zero impersonation awareness. The permission cache loads the target's permissions, and the usual `!Enabled` short-circuit still kills the session instantly if the target is disabled mid-flight. See [Authentication](/docs/authentication) for how the access/refresh token pair works in the normal case. ## The three endpoints | Step | Endpoint | Gate | | ----- | ------------------------------------------------- | ----------------------------- | | Start | `POST /api/v1/admin/users/{userId}/impersonate` | `Allow.AdminImpersonateUser` | | Refresh | `POST /api/v1/auth/refresh` (the normal one) | valid refresh cookie | | Stop | `POST /api/v1/admin/impersonation/stop` | presence of the `act` claim | ### Start The endpoint at `api/src/Slicekit.Api/Endpoints/v1/Admin/StartImpersonationEndpoint.cs` requires a free-text reason (recorded in the audit log), runs the guards, revokes the admin's current session, and issues a fresh one whose access token carries `act = adminId` and is capped at `Auth:ImpersonationMinutes`: ```csharp var adminId = principal.TryGetUserId() ?? throw new UnauthorizedAccessException(); var (rawRefresh, refreshHash) = tokenService.GenerateRefreshToken(); var result = await bus.InvokeAsync( new StartImpersonationCommand(adminId, adminSessionId, userId, refreshHash, request.Reason, deviceName), ct); if (!result.IsSuccess) return result.Error.ToProblem(); var lifetime = TimeSpan.FromMinutes(authOptions.Value.ImpersonationMinutes); var accessToken = tokenService.GenerateAccessToken( userId, result.Value.SessionId, result.Value.TargetPermissions, actingUserId: adminId, lifetime: lifetime); CookieHelper.SetAuthCookies(response, accessToken, rawRefresh, lifetime, lifetime, CookieHelper.IsSecure(httpContext)); ``` The handler (`api/src/Slicekit.Core/Features/Admin/StartImpersonation/Handler.cs`) creates a new `RefreshToken` row with `ImpersonatedByUserId = adminId` and raises `ImpersonationStartedEvent`. That nullable column is the only schema change impersonation needs, and it exists for one reason: to survive token rotation. ### How the session carries it across a refresh Access tokens are short-lived, so the client will hit `POST /api/v1/auth/refresh` long before a 30-minute impersonation window closes. The standard refresh path keeps the impersonation alive: the rotate-session handler copies `ImpersonatedByUserId` onto the new row, caps `ExpiresAtUtc` at `now + ImpersonationMinutes` (not the usual 30 days), and hands the acting id back so the new JWT keeps its `act` claim. Nothing in the refresh endpoint is impersonation-specific beyond threading that value through. This is why the DB column matters: the `act` claim alone would be lost on the next rotation. ### Stop `api/src/Slicekit.Api/Endpoints/v1/Admin/StopImpersonationEndpoint.cs` is deliberately gated on the **claim**, not a permission: ```csharp if (!principal.IsImpersonating) return AdminErrors.NotImpersonating.ToProblem(); var actingUserId = principal.TryGetActingUserId() ?? throw new UnauthorizedAccessException(); // ... ends the impersonation row, issues a fresh NORMAL session for the admin ``` The acting admin may not hold `Admin.ImpersonateUser` *through the target's* permission set, so requiring the permission here would trap them inside the session. The precondition is simply: do you carry an `act` claim? `IsImpersonating` and `TryGetActingUserId` live in `api/src/Slicekit.Api/Auth/ClaimsPrincipalExtensions.cs`. Stop ends the impersonation row (raising `ImpersonationEndedEvent`), then issues a fresh normal-lifetime session for the admin. ## The guards All of these run in the start handler and return a `ForbiddenError` that maps to a localized toast on the SPA: | Condition | Error | | --------------------------------- | ------------------------------ | | target is yourself | `CannotImpersonateSelf` | | target not found | `UserNotFound` | | target is an admin | `CannotImpersonateAdmin` | | target disabled or deleted | `CannotImpersonateDisabledUser`| | Stop called without an `act` claim| `NotImpersonating` | Two admins can impersonate the same user at once: each gets its own session family. Add a pre-flight check in the start handler if you want exclusivity. ## The audit trail This is the whole point. `AuditService.EnrichActor` (`api/src/Slicekit.Core/Auditing/AuditService.cs`) reads the `act` claim and stamps `AuditActor.OnBehalfOfUserId` onto **every** audit event raised during the session. So an unrelated action the admin performs as the target (a profile update, an API-key creation) carries both ids, and no individual emitter has to know impersonation is happening. On top of that ambient attribution, two dedicated events bracket the session: | Event | Action | Reason | | --------------------------- | ---------------------------- | ----------------------- | | `ImpersonationStartedEvent` | `Admin.ImpersonationStarted` | the admin's free-text reason | | `ImpersonationEndedEvent` | `Admin.ImpersonationEnded` | n/a | Both are pinned to the audit hash chain like every other security event. See [Auditing](/docs/auditing) for how actor attribution and the tamper-evident chain work. ## Frontend surface - The admin user-detail page shows an Impersonate action when the caller holds `Admin.ImpersonateUser` and is not already impersonating. It is disabled with a localized hint when the target is self, an admin, or disabled. - `ImpersonateUserDialog.tsx` collects the required reason via React Hook Form plus Zod, posts to Start, invalidates the `me` query, and navigates home. - `ImpersonationBanner.tsx` renders a sticky destructive bar whenever `me.impersonator` is set, with a Stop button: ```tsx if (!me?.impersonator) return null; // ... renders the banner with me.impersonator.email and a Stop button ``` `GET /api/v1/users/me` returns an `impersonator: { id, email } | null` built from the JWT `act` claim (threaded through `GetMeQuery` via `principal.TryGetActingUserId()`), not from the database. See [Adding a permission](/docs/adding-a-permission) for how the gating permission is declared and checked. ## Settings `AuthSettings` (`appsettings.json` under `Auth:*`): - `ImpersonationMinutes` (default `30`): rolling lifetime for both the access token and the impersonation refresh token, applied at Start and re-applied on every rotation. - `ImpersonationAbsoluteMinutes` (default `60`): hard cap on the refresh-token family. Past this, the admin must Stop and Start again. Set production values with env-var overrides (`Auth__ImpersonationMinutes=...`); do not edit `appsettings.json` for environment-specific values. ## Checklist - [ ] The caller holds `Admin.ImpersonateUser` and the start endpoint is reachable. - [ ] Start issues a session whose access token carries `act` and is capped at `ImpersonationMinutes`. - [ ] A refresh keeps the `act` claim and the `ImpersonatedByUserId` column, with the shortened expiry. - [ ] An action performed as the target shows `Actor.UserId = target` and `Actor.OnBehalfOfUserId = admin` in the audit log. - [ ] `Admin.ImpersonationStarted` and `Admin.ImpersonationEnded` events bracket the session. - [ ] Stop restores the admin's normal session and `GET /me` no longer returns an `impersonator`. - [ ] Each guard (self, admin, disabled target) surfaces a localized error. # Removing a feature > Cleanly delete a vertical slice across the API, frontend, permissions and tests. The inverse of [adding a vertical slice](/docs/vertical-slices). Slices remove cleanly because nothing else depends on them: a feature lives in its own folder and is discovered by convention, not wired into a central registry. The work is mostly deletion plus a few spots where a slice reached into shared surfaces (schema, integration-event contracts, personal-data handlers, permissions). Work the checklist top to bottom. The compiler and the test suite are your safety net: orphaned references surface as build errors, schema drift surfaces in feature and API tests. ## 1. Delete the Core slice and its endpoint A slice is three files in three projects: the command and handler in `Slicekit.Core`, the HTTP adapter in `Slicekit.Api`, and the tests. Remove all three. ```sh rm -r api/src/Slicekit.Core/Features/// rm api/src/Slicekit.Api/Endpoints/v1//Endpoint.cs rm api/tests/Slicekit.Feature.Tests/Features//Tests.cs ``` Also remove any API integration test under `api/tests/Slicekit.Api.Tests/` that covered the endpoint. ## 2. Check the integration-event contract If the slice published an `IIntegrationEvent` (look under `api/src/Slicekit.Core/IntegrationEvents/`), decide based on consumers: - **Other services consume it:** keep the contract, retire only the publisher, and note that the event is no longer emitted. - **Nobody consumes it:** delete the contract too. Grep before you delete: ```sh rg "" --type cs ``` See [CQRS and events](/docs/cqrs-and-events) for how raised and published events flow through the outbox. ## 3. Drop owned schema If the slice owned tables or columns that nothing else uses, drop them with a new EF migration: ```sh dotnet ef migrations add Drop \ --project api/src/Slicekit.Core \ --startup-project api/src/Slicekit.Api \ --context AppDbContext ``` See [adding a migration](/docs/adding-a-migration) for outbox-aware sequencing if the dropped tables sat alongside Wolverine's transactional outbox. ## 4. Strip personal-data branches If the slice persisted user-personal data, remove its branch from the export and delete handlers: - `api/src/Slicekit.Core/Features/Users/ExportMyData/Handler.cs` - `api/src/Slicekit.Core/Features/Users/DeleteUser/Handler.cs` Update their tests in the same change so the export and delete paths stay green. ## 5. Tidy API versioning If the deleted endpoint was the last one in a published API version, tidy `api/src/Slicekit.Api/Configuration/Versioning.cs` and `api/src/Slicekit.Api/Endpoints/RegisterEndpoints.cs`. This is the inverse of standing a new version up. ## 6. Remove orphaned permissions If the slice required a permission via `Allow.`, check whether anything else still references it in `api/src/Slicekit.Core/Permissions/Allow.cs`: ```sh rg "Allow." --type cs ``` If the permission is now orphaned, remove the constant and add a migration that deletes the permission row. See [adding a permission](/docs/adding-a-permission) for the inverse. ## 7. Remove the frontend slice If the API slice had a UI counterpart: - Delete `frontend/src/features//`. - Drop any routes under `frontend/src/routes/` that mounted the slice, and remove nav entries that linked to them. - Remove the slice's i18n keys from `frontend/src/shared/i18n/locales/en.json` and `nl.json`, plus any `errorCodeMap.ts` entries the slice added. Keeping the two locale files matched keeps the locale-completeness check green. ```sh cd frontend && pnpm typecheck && pnpm lint ``` Typecheck and lint surface orphaned imports the file deletes left behind. ## Verify - `dotnet build api/slicekit.slnx` passes. Compile errors expose orphaned references the grep missed. - `dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo` passes (the fast loop). - `dotnet test api/slicekit.slnx --nologo` passes. The full suite catches schema drift in feature and API tests. - `git grep ` returns nothing. ## Checklist - [ ] Core slice folder deleted (`Features///`). - [ ] `Slicekit.Api` endpoint deleted, plus its feature and API integration tests. - [ ] Integration-event contract kept or deleted based on consumers. - [ ] Migration added for any tables or columns the slice owned. - [ ] Personal-data branches removed from `ExportMyData` and `DeleteUser`, tests updated. - [ ] API versioning tidied if this was the last endpoint in a version. - [ ] Orphaned `Allow.` permission removed, with a migration deleting the row. - [ ] Frontend feature folder, routes, nav entries and i18n keys removed. - [ ] Build, fast suite and full suite green, `git grep` clean. # Testing a feature > Unit-test handlers and aggregates with the fast suite, and cover endpoints with Testcontainers integration tests. ## Three test projects, two speeds A vertical slice is tested at the level its risk lives. Pure logic, an aggregate invariant, an error mapping, a validator, runs as a millisecond unit test. Anything that touches the real schema, ASP.NET Identity or the domain-event flow runs against a real Postgres in a container. The split maps onto three projects under `api/tests/`: ``` api/tests/ Slicekit.Unit.Tests/ # pure logic: aggregates, handlers with mocked ports, validators Slicekit.Architecture.Tests/ # NetArchTest rules: slices stay isolated, layers stay clean Slicekit.Feature.Tests/ # integration: real Postgres via Testcontainers, no HTTP ``` The first two are the **fast suite**. They need no Docker and finish in a few seconds, so they are the loop you run on every change: ```sh dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo ``` The third, `Slicekit.Feature.Tests`, is slower (Testcontainers spins up a Postgres per fixture) and is the boundary where you prove the slice works against the real database. This page walks all three: a unit test for a handler and an aggregate, the architecture tests that keep slices honest, and an integration test backed by Testcontainers. For the shape of the slice itself, see [Adding a vertical slice](/docs/vertical-slices). ## 1. Unit-test the aggregate The aggregate owns its invariants, so test it in isolation with no infrastructure. These live in `Slicekit.Unit.Tests/Domain/`. Construct the aggregate, call a method, assert on the resulting state: ```csharp using Slicekit.Core.Domain; namespace Slicekit.Unit.Tests.Domain; public sealed class UserTests { [Fact] public void CreateLocal_Sets_Defaults_For_New_Local_Account() { var user = User.CreateLocal("user@example.com"); user.Id.ShouldNotBe(Guid.Empty); user.Email.ShouldBe("user@example.com"); user.Enabled.ShouldBeTrue(); user.IsAdmin.ShouldBeFalse(); } [Fact] public void AssignPermission_Is_Idempotent_And_Returns_False() { var user = User.CreateLocal("user@example.com"); var permission = new Permission { Id = 7, Name = "Test.Permission" }; user.AssignPermission(user.Id, permission); var added = user.AssignPermission(user.Id, permission); added.ShouldBeFalse(); user.UserPermissions.ShouldHaveSingleItem(); } } ``` Assertions use [Shouldly](https://docs.shouldly.org/). `Globals.cs` in the project glob-imports `Shouldly`, `Xunit`, `Slicekit.Core.Common` and the error namespaces, so those types are unqualified inside test files. ## 2. Unit-test the handler A handler that does not touch the database can also be a unit test: inject its ports as NSubstitute mocks and assert on what it dispatched. These live next to the slice they cover, for example `Slicekit.Unit.Tests/Auth/`: ```csharp using NSubstitute; using Slicekit.Core.Domain.Events; using Slicekit.Core.Features.Auth.RevokeAllRefreshTokens; using Wolverine; namespace Slicekit.Unit.Tests.Auth; public sealed class RevokeSessionsOnUserDisabledHandlerTests { [Fact] public async Task Publishes_Revoke_Command_When_User_Disabled() { var bus = Substitute.For(); var userId = Guid.NewGuid(); var handler = new RevokeSessionsOnUserDisabledHandler(bus); await handler.Handle(new UserEnabledChangedEvent(Guid.NewGuid(), userId, Enabled: false)); await bus.Received(1).PublishAsync( Arg.Is(c => c.UserId == userId), Arg.Any()); } } ``` The rule of thumb: if you can mock the handler's ports and still test the behaviour you care about, keep it here. The moment the assertion is about EF behaviour, the schema, or cross-aggregate state, promote it to an integration test (section 4). ## 3. Let the architecture tests guard the slice `Slicekit.Architecture.Tests` uses [NetArchTest](https://github.com/BenMorris/NetArchTest) to enforce the boundaries that make vertical slices work. You do not write a new test per feature: the existing rules scan every type in `Slicekit.Core` and fail if your slice breaks one. The two that bite most often: ```csharp [Fact] public void Feature_Slices_Must_Not_Depend_On_Each_Other() { foreach (var ns in FeatureNamespaces) { var otherFeatures = FeatureNamespaces.Where(f => f != ns).ToArray(); var result = Types.InAssembly(CoreAssembly) .That().ResideInNamespace(ns) .ShouldNot().HaveDependencyOnAny(otherFeatures) .GetResult(); result.IsSuccessful.ShouldBeTrue(); } } ``` If your new slice references a type from another slice, this fails and names the offending type. The fix is to share through `Slicekit.Core.Domain`, not across feature folders. A companion rule, `Domain_Must_Not_Depend_On` (a theory with one case per forbidden dependency), keeps the domain model free of EF Core, Identity, Wolverine and HTTP. Run the fast suite and these pass or point straight at the line to fix. ## 4. Integration-test the endpoint with Testcontainers When the slice touches the database, identity or the domain-event flow, write an integration test in `Slicekit.Feature.Tests/Features//`. These instantiate the handler directly against a real Postgres (Testcontainers), call `HandleAsync`, and assert on the `Result`, the queued `OutgoingMessages`, and any domain events the aggregate raised. No HTTP, no mocking of EF. ### The fixture `DatabaseFixture` spins up one Postgres container per xUnit collection, applies every migration, and seeds the permission catalog. Tests share the container; each test gets a fresh `AppDbContext` and truncates the user-data tables on setup. Inherit `FeatureTestBase` and you get a clean `Db` per test: ```csharp [Collection("Database")] public abstract class FeatureTestBase(DatabaseFixture db) : IAsyncLifetime { protected AppDbContext Db { get; private set; } = null!; public async Task InitializeAsync() { Db = db.CreateDbContext(); await db.TruncateUserDataAsync(); } public async Task DisposeAsync() => await Db.DisposeAsync(); } ``` The `[Collection("Database")]` attribute is required and is inherited from the base class. Without it xUnit treats the class as parallel-isolated and spins up its own container. ### The test Arrange state with the seeding helpers, call the handler, assert on all three outputs: ```csharp public sealed class CreateProjectTests(DatabaseFixture db) : FeatureTestBase(db) { [Fact] public async Task Happy_Path() { // Arrange: seed, save, clear the change tracker var user = User.CreateLocal("u@example.com"); Db.Users.Add(user); await Db.SaveChangesAsync(); Db.ChangeTracker.Clear(); // Act: instantiate the handler, call HandleAsync var handler = new CreateProjectCommandHandler(Db); var (result, messages) = await handler.HandleAsync(new CreateProjectCommand(user.Id, "First")); // Assert: Result, OutgoingMessages, domain events result.IsSuccess.ShouldBeTrue(); Db.DomainEvents().OfType().ShouldHaveSingleItem(); } } ``` `IdentityHelper` builds the ASP.NET Identity plumbing that registering users needs: ```csharp var userManager = IdentityHelper.BuildUserManager(Db); var (appUser, domainUser) = await IdentityHelper.SeedUserAsync(Db, userManager, "u@example.com", "Password1!"); ``` `SeedUserAsync` creates both the `ApplicationUser` and the `User` aggregate and links them by `Id`. Pass `password: null` for a passwordless (OAuth-only) account. For a domain-only seed with no Identity row, use `IdentityHelper.SeedDomainUser(Db, "u@example.com")`. ### Domain events The fixture wires a capturing interceptor into every `AppDbContext`, snapshotting `IDomainEvent`s on `SaveChanges` at the same point Wolverine's transactional middleware uses in production. Read them back with the extension: ```csharp var events = Db.DomainEvents(); events.OfType().ShouldHaveSingleItem(); ``` `DomainEvents()` is cumulative per `Db` instance; call `Db.ClearDomainEvents()` for a clean slate between multiple `SaveChanges` calls in one test. ### Outgoing messages Command handlers return `(Result, OutgoingMessages)`. The `OutgoingMessages` are the messages the handler would dispatch via Wolverine. Assert on them directly; the dispatch itself does not run, which is the right boundary because the message contract is what your slice owns and the recipient's behaviour is its own test: ```csharp var (result, messages) = await handler.HandleAsync(command); result.IsSuccess.ShouldBeTrue(); var email = messages.OfType().ShouldHaveSingleItem(); email.Recipient.ShouldBe("u@example.com"); ``` ### Mocking ports, not EF External dependencies (`IAuditService`, breached-password checks, OAuth providers) arrive as interfaces on the handler constructor. Mock those with NSubstitute: ```csharp var audit = Substitute.For(); var handler = new UpdateConsentCommandHandler(Db, audit); await handler.HandleAsync(command); await audit.Received(1).EmitAsync( Arg.Is(e => e.Category == AuditCategory.Consent && e.Action == "Consent.Granted"), Arg.Any()); ``` Do not mock `AppDbContext`, and do not mock `UserManager`. Using the real Postgres through the fixture and the helpers is the whole point of an integration test. ## Running the suites ```sh # Fast loop: unit + architecture, no Docker, a few seconds dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo # Integration: needs Docker running for Testcontainers dotnet test api/tests/Slicekit.Feature.Tests --nologo ``` If Postgres will not start, `docker compose ps` confirms Docker is up. The Testcontainers Postgres is its own container, separate from the one in `docker-compose.yml`. ## Checklist - Aggregate invariants and pure logic covered by unit tests in `Slicekit.Unit.Tests`. - Handlers with mockable ports unit-tested with NSubstitute; no EF or `UserManager` mocks. - Fast suite green: `dotnet test api/tests/Slicekit.Unit.Tests api/tests/Slicekit.Architecture.Tests --nologo`. - Architecture tests pass, so the new slice does not reach across feature boundaries. - Database, identity or domain-event behaviour covered by an integration test in `Slicekit.Feature.Tests`, inheriting `FeatureTestBase` with `[Collection("Database")]`. - Asserted on all three outputs that matter: the `Result`, the `OutgoingMessages`, and the raised domain events. - `dotnet test api/tests/Slicekit.Feature.Tests --nologo` passes with Docker running. --- # Frontend # Frontend overview > The React SPA: its slice-per-feature layout, routing with TanStack, and the shadcn/ui design system. ## The stack The frontend is a Vite + React 19 single-page app written in TypeScript. It uses: - **TanStack Router** for type-safe routing. - **TanStack Query** for server state: caching, invalidation and background refetching. - **shadcn/ui** components on **Tailwind v4**, with light and dark themes. ## Slice-per-feature Like the API, the frontend is organised by feature rather than by file type. A feature folder holds everything it needs: ``` features/ projects/ api.ts // typed client calls queries.ts // TanStack Query hooks ProjectList.tsx // components route.tsx // the TanStack route ``` Shared primitives (the UI kit, the API client, theme handling) live in `components/` and `lib/`. Anything feature-specific stays inside the feature. ## Routing Routes are defined per feature and composed into the router. Because TanStack Router is type-safe, navigation and route params are checked at compile time, so a renamed route surfaces as a type error, not a runtime 404. ## Server state with TanStack Query Data fetching lives in query hooks, one concern per hook. Components call a hook and render; the hook owns caching, loading and error state. Mutations invalidate the queries they affect, so the UI stays consistent without manual refetching. ```ts return useQuery({ queryKey: ['projects'], queryFn: listProjects }) } ``` ## Theming The app supports light, dark and system themes. The choice is persisted under the `slicekit.theme` key and applied before first paint to avoid a flash, the same key this marketing site uses, so the two agree on the active theme. ## Talking to the API All requests go through one typed client that handles cookies and CSRF. That contract is important enough to have [its own page](/docs/api-client). # The typed API client > How the frontend talks to the API: one typed client, cookies and CSRF handled for you, wrapped in TanStack Query. ## One client, one contract Every call from the SPA to the API goes through a single typed client. Centralising it means cookies, CSRF, error handling and base URLs are configured in exactly one place; features just call typed functions. ```ts // features/projects/api.ts ``` ## Cookies and CSRF The client sends requests with credentials so the session cookie is included automatically. For state-changing requests it attaches the CSRF token the API expects. None of this is the feature author's concern: call `api.post` and the protection is applied. ## Errors The API returns a consistent error shape, and the client surfaces it as a typed error. Error codes map to translation keys (snake_case), so the UI can show a localized message without scattering string literals through components. ```ts try { await createProject({ name }) } catch (err) { // err.code -> 'project_name_taken' -> localized message } ``` ## Wrapped in TanStack Query The raw client functions are wrapped in query and mutation hooks per feature. Components depend on the hooks, not the transport: ```ts const qc = useQueryClient() return useMutation({ mutationFn: createProject, onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }), }) } ``` ## Keeping types honest The request and response types are hand-written to mirror the API's shapes; there is no codegen step (see [adding a frontend feature](/docs/adding-a-frontend-feature)). Centralising the client keeps every call site consistent, but keeping those types in line with the API is a manual discipline: rename a field on the server and the TypeScript still compiles, so the interactive API reference at `/scalar` on the running API is the source of truth for every endpoint. If you want the wire enforced at build time, generate the types from the published OpenAPI document (for example with `openapi-typescript`) and # Adding a frontend feature > Build a new feature slice in the React SPA: route, data hooks, components and types. A frontend feature is a single vertical slice under `frontend/src/features//`. The folder mirrors the API slice name (`features/api-keys/` pairs with `api/src/Slicekit.Core/Features/ApiKeys/`), so the contract on both sides reads as one feature. Nothing else in the codebase reaches inside a slice: every import crosses the boundary through `api.ts`, `hooks.ts`, or a component. If you have not read [Frontend overview](/docs/frontend-overview) yet, start there for the stack (Vite, React 19, TanStack Router and Query, shadcn/ui on Tailwind v4). This page is the recipe for one slice. ## Slice layout ``` frontend/src/features// ├── api.ts Thin per-endpoint wrappers around apiFetch / apiBlob ├── types.ts Request and response shapes (handwritten, no codegen) ├── schemas.ts Zod schema factories taking TFunction (forms only) ├── hooks.ts TanStack useQuery / useMutation hooks └── components/ Slice-specific UI ``` Not every slice needs all five files. `features/data-export/` has no `types.ts`, `features/features/` is just `api.ts` plus `hooks.ts`. Add a file when the slice earns it. ## 1. Types: the wire shapes Mirror the API's request and response records with camelCase fields. These are handwritten, there is no codegen step: ```ts id: string; name: string; keyHint: string; isActive: boolean; expiresAtUtc: string | null; createdAtUtc: string; scopedPermissions: string[]; }; name?: string; expiresAtUtc?: string | null; scopedPermissions?: string[]; }; apiKeyId: string; plainKey: string; keyHint: string; name: string; expiresAtUtc: string | null; }; ``` ## 2. API: endpoint wrappers One function per endpoint, grouped into a single object. Use `apiFetch` for JSON and `apiBlob` for binary downloads. The typed client owns cookies, CSRF and token refresh, so these wrappers stay thin. See [The API client](/docs/api-client) for what it handles. ```ts list: (page = 1, pageSize = 20) => apiFetch(`/api/v1/api-keys/?page=${page}&pageSize=${pageSize}`), create: (body: CreateApiKeyRequest) => apiFetch('/api/v1/api-keys/', { method: 'POST', body }), remove: (id: string) => apiFetch(`/api/v1/api-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }), }; ``` `apiFetch` JSON-serialises `body` for you. For `multipart/form-data` uploads, pass a `FormData` instance as `body` and do not set `Content-Type`: the browser fills in the boundary. ## 3. Schemas: Zod factories (forms only) Form validation lives in `schemas.ts` as factory functions that take i18next's `TFunction`. Passing `t` in means validation messages re-render when the locale changes: ```ts z.object({ name: z.string().min(1, t('validation.required')).max(80, t('validation.maxLength', { max: 80 })), scopedPermissions: z.array(z.string()).min(1, t('api_keys.permission_required')), }); ``` Components build the schema with `useMemo(() => createApiKeySchema(t), [t])` so it only rebuilds on a locale change. ## 4. Hooks: TanStack Query and Mutation Data fetching lives in query hooks, one concern per hook. Components call a hook and render; the hook owns caching, loading and error state. Export the query keys so adjacent slices can invalidate them: ```ts return useQuery({ queryKey: [...apiKeysQueryKey, page, pageSize], queryFn: () => apiKeysApi.list(page, pageSize), }); } const queryClient = useQueryClient(); return useMutation({ mutationFn: apiKeysApi.create, onSuccess: () => queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }), }); } ``` Mutations invalidate the queries they affect, so the UI stays consistent without manual refetching. Exporting query keys is the pattern that lets one slice refresh another: `meQueryKey` from `features/account/hooks.ts` is invalidated by any slice that mutates the current user. ## 5. Components: the UI Components compose shadcn/ui primitives, call the slice's hooks, and wire forms with React Hook Form plus the Zod schema. Every user-visible string goes through `t('namespace.key')`: ```tsx const { t } = useTranslation(); const schema = useMemo(() => createApiKeySchema(t), [t]); const form = useForm({ resolver: zodResolver(schema) }); const createMutation = useCreateApiKey(); async function onSubmit(values: CreateApiKeyValues) { try { await createMutation.mutateAsync(values); toast.success(t('api_keys.created')); } catch (err) { if (err instanceof AppError) err.applyToForm(form.setError); else toast.error(t('errors.request_failed', { status: 0 })); } } return
{/* fields */}
; } ``` `AppError.applyToForm` maps the API's validation problem details straight onto the matching form fields, so server-side rules surface inline next to the input that failed. ## 6. Wire into a route Routing is file-based under `frontend/src/routes/`. The filename is the path, dots are separators, and underscore-prefixed segments are pathless layouts (`_app` is the authenticated shell). Add a file that mounts your page component: ```tsx // frontend/src/routes/_app.settings.api-keys.tsx component: ApiKeysPage, }); ``` TanStack Router regenerates `routeTree.gen.ts` automatically on save (or while `pnpm dev` runs). Never edit that file by hand. Because routing is type-safe, a renamed route surfaces as a compile error, not a runtime 404. ## 7. Gate on a permission If the API enforces a permission, mirror the constant in `shared/auth/permissions.ts` and gate the UI on it so the action hides for users who lack it. The full pattern, including how the constants stay in sync with the backend, is in [Adding a permission](/docs/adding-a-permission). ## Checklist - [ ] `types.ts`: camelCase request and response shapes mirroring the API records. - [ ] `api.ts`: one `apiFetch` or `apiBlob` wrapper per endpoint. - [ ] `schemas.ts`: Zod schema factories taking `TFunction` (forms only). - [ ] `hooks.ts`: TanStack query and mutation hooks, with query keys exported. - [ ] `components/`: UI via React Hook Form plus the schema, every string through `t(...)`. - [ ] A route under `src/routes/`, letting the router regenerate `routeTree.gen.ts`. - [ ] The permission constant mirrored in `shared/auth/permissions.ts` if the UI gates on it. - [ ] i18n keys added to both `shared/i18n/locales/en.json` and `nl.json`. - [ ] Verify: `pnpm typecheck`, `pnpm lint`, then a manual pass in `pnpm dev` (happy path, invalid input, and a language toggle to confirm labels and validation messages re-localise). # Adding a language > Add a new language to the SPA, where translation namespaces live, and how strings are looked up. ## Where i18n lives Everything for translations sits under `frontend/src/shared/i18n/`: ``` frontend/src/shared/i18n/ i18n.ts // i18next init, SUPPORTED_LOCALES, LOCALE_LABELS index.ts // public re-exports LanguageSwitcher.tsx // the header dropdown errorCodeMap.ts // FluentValidation code -> i18n key locales.test.ts // keeps locales in sync locales/ en.json // the source of truth nl.json ``` `i18n.ts` initialises i18next with every locale bundled at build time (no async loading). The detected locale is persisted in `localStorage` under `slicekit.locale`, falling back to the browser's `navigator` language, then to `en`: ```ts en: 'English', nl: 'Nederlands', }; ``` `` is mounted in both the public layout and the authenticated header. It maps over `SUPPORTED_LOCALES`, labels each entry with `LOCALE_LABELS`, and calls `i18n.changeLanguage(locale)`. It renders nothing when only one locale is configured, so the dropdown appears the moment you add a second language. ## How strings are looked up There are **no hard-coded UI strings.** Every user-visible string goes through `t('namespace.key')` from `useTranslation()`: ```tsx const { t } = useTranslation(); return {t('common.save_changes')}; ``` Keys are namespaced by surface, so each feature owns its strings: `common.*` for reusable actions (`cancel`, `save_changes`, `copy`), `nav.*` for navigation, `auth..*` for the auth screens, `account.*` for settings, `validation.*` for form messages, and `errors.*` for `AppError` details keyed by camelCase `errorCode`. Reuse `common.*` before inventing a key: adding `account.cancel_changes` when `common.cancel` exists creates drift that translators have to keep in sync by hand. `en.json` is the source of truth. Every other locale file mirrors its key structure exactly. ## Adding a language Suppose you are adding German (`de`). ### 1. Add the locale file Copy `en.json` as a starting point so every key is present, then translate the values: ```sh cp frontend/src/shared/i18n/locales/en.json frontend/src/shared/i18n/locales/de.json ``` Keep the keys identical to `en.json`. The sync test (below) fails if a key is missing. ### 2. Register the locale in `i18n.ts` Import the file, add it to the i18next `resources`, append the code to `SUPPORTED_LOCALES`, and give it a label: ```ts en: 'English', nl: 'Nederlands', de: 'Deutsch', // new }; void i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources: { en: { translation: en }, nl: { translation: nl }, de: { translation: de }, // new }, fallbackLng: 'en', supportedLngs: SUPPORTED_LOCALES, // ... }); ``` `Locale` is derived from `SUPPORTED_LOCALES`, so `LOCALE_LABELS` and the switcher stay type-checked against the new code automatically. ### 3. The language switcher wires itself up No change needed. `LanguageSwitcher.tsx` reads `SUPPORTED_LOCALES` and `LOCALE_LABELS`, so the new entry shows up in the dropdown as soon as it is registered. ### 4. Keep the locale in sync `locales.test.ts` walks every key in `en.json` and asserts each other locale contains it. Wire the new locale into the completeness check next to `nl`: ```ts describe('de.json completeness', () => { const enKeys = collectKeys(en); const deKeys = new Set(collectKeys(de)); it.each(enKeys)('de.json has key "%s"', (key) => { expect(deKeys.has(key)).toBe(true); }); }); ``` The same file also checks that every i18n key referenced by `VALIDATION_CODE_MAP` (the FluentValidation code to key map in `errorCodeMap.ts`) resolves in each locale, so server-driven validation messages cannot ship half-translated. Run it with `pnpm test`. ## Interpolation and markup Pass variables as the second argument; i18next substitutes `{{name}}` placeholders: ```ts t('dashboard.welcome', { name }); // en.json: "welcome": "Welcome, {{name}}" // de.json: "welcome": "Willkommen, {{name}}" ``` For strings that contain markup, use `` rather than concatenating JSX, so translators can reorder fragments freely: ```tsx }} /> ``` ## Error message fallbacks `AppError` falls back to `t('errors.request_failed', { status })` when the server returns no `ProblemDetails` body (a network failure, or a 5xx with no payload). That key must exist in every locale. For known error codes the lookup is `errors.{camelCase(errorCode)}`; how the typed client resolves them is covered in [the typed API client](/docs/api-client). ## Checklist - [ ] Copied `en.json` to `locales/.json` and translated the values, keeping keys identical. - [ ] Imported the file in `i18n.ts` and added it to `resources`. - [ ] Appended the code to `SUPPORTED_LOCALES` and added a `LOCALE_LABELS` entry. - [ ] Added a `.json` completeness block to `locales.test.ts`. - [ ] `pnpm test` passes (no missing keys, validation keys resolve). - [ ] `pnpm dev`, then toggle the language switcher: every visible string changes, including a forced 400 error toast. See [the frontend overview](/docs/frontend-overview) for how features and shared modules fit together. # Building a form > Build a validated form with React Hook Form and Zod, wired to the typed API client and its errors. ## One stack, every form Every form in the SPA uses the same three pieces: **Zod** for the schema, **React Hook Form** for state, and the shadcn-style `Form` primitives in `shared/ui/form.tsx` for the markup. There are no exceptions, not even single-field forms. The i18n wiring and the server-error mapping are the same shape regardless of size, so the smallest form pays the same tax as the largest. A form is built from four parts that live in a feature slice: ``` frontend/src/features/account/ schemas.ts // Zod schema factories + inferred value types api.ts // typed apiFetch calls hooks.ts // TanStack Query mutation hooks components/ChangePasswordForm.tsx // the form itself ``` ## 1. Write the schema as a factory Schemas live in `features//schemas.ts`. Each one is a **factory function** that takes i18next's `TFunction` and returns a `z.object`, so the validation messages resolve in the active locale: ```ts return z .object({ currentPassword: z.string().min(1, t('validation.current_password_required')), newPassword: z.string().min(8, t('validation.password_min')), confirmPassword: z.string(), }) .refine((v) => v.newPassword === v.confirmPassword, { message: t('validation.passwords_must_match'), path: ['confirmPassword'], }); } ``` Why a factory and not a module-level `z.object` with static strings? A module-level schema freezes its messages in whatever locale was active when the module first loaded. The factory rebuilds the schema whenever `t` changes, which is what the language switcher needs. Export the inferred type with `z.infer>` so the form is typed from the schema, not duplicated by hand. Cross-field rules (`confirmPassword` must match `newPassword`) go in `.refine()` with an explicit `path`, so the error lands on the right field. ## 2. Add the typed call and a mutation hook The form never calls `fetch` directly. The slice's `api.ts` wraps the typed client `apiFetch`, which handles cookies, CSRF and error parsing for you (see [the typed API client](/docs/api-client)): ```ts changePassword: (body: ChangePasswordRequest) => apiFetch('/api/v1/users/me/change-password', { method: 'POST', body }), }; ``` Wrap that call in a TanStack Query mutation in `hooks.ts`: ```ts return useMutation({ mutationFn: accountApi.changePassword }); } ``` ## 3. Wire React Hook Form to the schema In the component, build the schema with `useMemo` keyed on `t`, hand it to `zodResolver`, and pass sensible `defaultValues`: ```tsx const { t } = useTranslation(); const change = useChangePassword(); const schema = useMemo(() => changePasswordSchema(t), [t]); const form = useForm({ resolver: zodResolver(schema), defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '' }, }); // ... } ``` ## 4. Render with the Form primitives The `Form` primitives in `@/shared/ui/form` connect each field to React Hook Form through context, so you never read `form.formState.errors` by hand. `Form` is the provider, `FormField` binds a `name` to a `Controller`, and `FormMessage` reads that field's error and renders it (or nothing): ```tsx return (
( {t('account.change_password.current_password_label')} )} /> {form.formState.isSubmitting ? t('account.change_password.submitting') : t('account.change_password.submit')}
); ``` `FormControl` wires the rendered input (`{...field}`) to the field's id, `aria-invalid` and `aria-describedby`, so accessibility is handled. Any input works inside it: `Input`, `PasswordInput`, a `Select`, a `Checkbox`. Keep validation in Zod, not on the ``: no `required`, `pattern` or `minLength` attributes. ## 5. Submit and surface server errors The submit handler calls the mutation with `mutateAsync` and catches failures. The typed client throws an `AppError` (see [error handling](/docs/error-handling)), whose `applyToForm` maps the server's per-field validation errors back onto the matching React Hook Form fields. It returns `false` when there were no field errors to apply, which is your cue to fall back to a field message or a toast: ```tsx async function onSubmit(values: ChangePasswordValues) { try { await change.mutateAsync({ currentPassword: values.currentPassword, newPassword: values.newPassword, }); toast.success(t('account.change_password.success_toast')); form.reset(); } catch (err) { if (err instanceof AppError && !err.applyToForm(form.setError)) { form.setError('currentPassword', { message: err.isUnauthenticated ? t('account.change_password.invalid_password') : (err.detail ?? t('account.change_password.error')), }); } else if (!(err instanceof AppError)) { toast.error(t('account.change_password.error')); } } } ``` `applyToForm` does the translation for you: it reads the server's validation codes, maps them through the shared code map, resolves the message in the active locale, and converts `PascalCase` field names to the camelCase your form uses. You just hand it `form.setError`. ## Things to avoid - **No module-level schema constants.** They freeze their messages in one locale. Always a factory plus `useMemo`. - **No native validation attributes** (`required`, `pattern`, `minLength`). Zod is the single source of truth. - **No string concatenation for messages.** Translators cannot reorder fragments. Use interpolation (`t('validation.password_min', { min: 8 })`) or `` for embedded markup. - **No reaching into `form.formState.errors` to display errors.** `FormMessage` reads them through context. - **No raw `fetch`.** Go through the slice's `api.ts` and `apiFetch`. ## Checklist - [ ] Schema is a factory taking `TFunction`, with the value type exported via `z.infer>`. - [ ] The component builds the schema with `useMemo` keyed on `t` and passes it to `zodResolver`. - [ ] Every field is a `FormField` with a `FormMessage`; inputs sit inside `FormControl`. - [ ] Submit calls a TanStack mutation through `mutateAsync`, not raw `fetch`. - [ ] The catch block runs `AppError.applyToForm(form.setError)` and falls back to a toast. - [ ] `pnpm typecheck` passes and toggling the language switcher re-renders visible errors. # Permissions in the UI > Show, hide and guard UI by the current user's permissions, mirrored from the API. ## Where permissions come from Permissions are defined and enforced on the API, not invented in the SPA. The catalog lives in `api/src/Slicekit.Core/Permissions/Allow.cs` as `Area.Action` strings, and every endpoint declares the permission it requires (see [Adding a permission](/docs/adding-a-permission)). The frontend only **mirrors** that catalog so it can hide UI the user could never use anyway. The server is still the source of truth: a hidden button is a courtesy, a `403` from the API is the real gate. The current user's permission set arrives with the rest of their profile. The `GET /me` request loads `{ id, isAdmin, permissions, ... }` once, and TanStack Query caches it under the `['me']` key. Every component that reads permissions hits that one cache, so gating N components costs zero extra requests. ## The mirrored enum `frontend/src/shared/auth/permissions.ts` holds a plain const map plus a `hasPermission` helper: ```ts UserUpdateProfile: 'User.UpdateProfile', UserListSessions: 'User.ListSessions', UserListApiKeys: 'User.ListApiKeys', UserCreateApiKey: 'User.CreateApiKey', AdminListUsers: 'Admin.ListUsers', AdminSetPermissions: 'Admin.SetPermissions', // ... } as const; permissions: readonly string[] | undefined, name: PermissionName, ): boolean { return !!permissions?.includes(name); } ``` Each constant maps one-to-one to `Allow.` in `Allow.cs`: same `Area.Action` shape, same casing. The string value is what the server returns in the `permissions` array. You only need to list the permissions the SPA actually renders against, not the whole catalog. ## Reading permissions in a component `frontend/src/shared/hooks/use-permissions.ts` wraps the `['me']` query and exposes a `has` predicate: ```ts const { data } = useCurrentUser(); const permissions = data?.permissions ?? []; return { permissions, has: (name: PermissionName) => hasPermission(permissions, name), }; } ``` Use it to render an action only when the user holds the matching permission: ```tsx const { has } = usePermissions(); if (!has(Permission.UserCreateApiKey)) return null; return {t('api_keys.create')}; } ``` Prefer returning `null` over rendering a disabled control: a control the user can never use is just noise. Reserve the disabled state for things they could do but cannot right now. ## Gating navigation The settings shell at `frontend/src/routes/_app.settings.tsx` builds its tab list from a typed array where each section carries an optional `permission`, then filters the list with `has`: ```tsx const sections: Section[] = [ { to: '/settings/profile', labelKey: 'profile', permission: Permission.UserUpdateProfile }, { to: '/settings/sessions', labelKey: 'sessions', permission: Permission.UserListSessions }, { to: '/settings/api-keys', labelKey: 'api_keys', permission: Permission.UserListApiKeys }, ]; function SettingsLayout() { const { has } = usePermissions(); const visible = sections.filter((s) => !s.permission || has(s.permission)); // render `visible` as tabs } ``` A section with no `permission` is always shown. Adding a new gated tab is one array entry. ## Route guards Hiding a link does not protect the route it points to. The route itself runs a `beforeLoad` guard that resolves the current user and redirects when the check fails. The admin layout at `frontend/src/routes/_app.admin.tsx` does this with the shared `ensureAuthenticated` helper: ```tsx beforeLoad: async ({ context, location }) => { const me = await ensureAuthenticated(context.queryClient, location.pathname); if (!me.isAdmin) throw redirect({ to: '/' }); }, component: AdminLayout, }); ``` `ensureAuthenticated` (in `frontend/src/shared/auth/guard.ts`) fetches the `['me']` query, redirects to `/auth/login` when there is no session, and otherwise returns the `CurrentUser`. Because it reads the same cache as `usePermissions`, the guard runs without a second network round trip. To gate a route on a specific permission rather than the admin flag, check `me.permissions.includes(...)` in the same `beforeLoad`: ```tsx beforeLoad: async ({ context, location }) => { const me = await ensureAuthenticated(context.queryClient, location.pathname); if (!me.permissions.includes(Permission.AdminListUsers)) throw redirect({ to: '/' }); }, ``` For more on how sessions and the `['me']` query are wired, see [Authentication](/docs/authentication). ## Keeping the mirror in sync with the API Permissions live on the API; the SPA copy can drift. Two failure modes to keep in mind: - **The enum is stale.** A permission was added to `Allow.cs` but not to `permissions.ts`. The fix is mechanical: add the matching `Area.Action` entry. Do it in the same change that touches the API, the way [Adding a permission](/docs/adding-a-permission) describes. - **The cache is stale.** The `['me']` query is cached for 60 seconds (`staleTime`), and the API caches the user's permission set briefly too. After a server-side change (an admin grant, a permission added to the catalog), the UI can show stale permissions until the cache refreshes or the user signs out and back in. This is deliberate: the alternative is refetching `/me` on every navigation. When a privileged action returns `403` even though the button was visible, treat it as a sync gap, not a bug. Surface the error as a toast and invalidate the `['me']` query so the next render reflects reality: ```ts const queryClient = useQueryClient(); await queryClient.invalidateQueries({ queryKey: meQueryKey }); ``` ## Checklist - [ ] The permission exists in `api/src/Slicekit.Core/Permissions/Allow.cs` and the endpoint requires it. - [ ] A matching `Area.Action` entry exists in `frontend/src/shared/auth/permissions.ts`, same casing. - [ ] Components gate actions with `usePermissions().has(...)`, returning `null` for unavailable actions. - [ ] Navigation entries carry a `permission` and are filtered with `has`. - [ ] The route is guarded in `beforeLoad` (via `ensureAuthenticated` plus an `isAdmin` or `permissions.includes` check), not just hidden in the menu. - [ ] A `403` from the API is handled gracefully (toast plus invalidate `['me']`), since the UI gate is advisory and the server is authoritative. - [ ] `pnpm typecheck` passes. Adding a whole feature behind a permission? Pair this with [Adding a frontend feature](/docs/adding-a-frontend-feature) and the [Frontend overview](/docs/frontend-overview). --- # Operations # Observability > Traces, metrics and logs through OpenTelemetry into Grafana, and how to instrument your own slices. ## The pipeline The API emits all three OpenTelemetry signals to an **OpenTelemetry Collector**, which fans them out to dedicated backends, all explored through Grafana: | Signal | Backend | | ------- | ----------- | | Traces | Tempo | | Metrics | Prometheus | | Logs | Loki | The collector, the backends and Grafana are all in `docker-compose.yml`, with Grafana dashboards provisioned from `deploy/`. Open Grafana at [localhost:3010](http://localhost:3010) once the stack is up. ## Logs and audit Application logs go through **Serilog** and out over OTLP to Loki. Audit events use the same pipeline rather than a separate table, so audit retention is a Loki configuration, and the API stays free of purge jobs. ## Tracing a request Because the whole request path is instrumented, a single browser action produces one trace spanning the endpoint, the Wolverine handler, the database call and any published messages. When something is slow, open the trace, find the expensive span, and jump to its correlated logs. ## Instrumenting your own slice Most of the time the built-in instrumentation is enough. When a slice does something worth measuring, add a span or a metric explicitly: ```csharp private static readonly ActivitySource Activity = new("Slicekit.Search"); private static readonly Counter Rebuilds = new Meter("Slicekit.Search").CreateCounter("search.index.rebuilds"); using var span = Activity.StartActivity("rebuild-search-index"); span?.SetTag("project.id", projectId); Rebuilds.Add(1); ``` Spans you start nest automatically under the request trace, so custom work shows up in context. ## Alerts Alertmanager is included in the stack. Define alert rules against the Prometheus metrics your product cares about (error rate, queue depth, latency) and route notifications where your team will see them. # Deployment > Building the production images, configuration through environment variables, and running the production stack. ## Production stack A second Compose file, `docker-compose.prod.yml`, builds the API image and runs the production topology. The API ships with a `Dockerfile`; the frontend builds to static assets served by your CDN or a static host. ```sh docker compose -f docker-compose.prod.yml up -d --build ``` ## Configuration is environment variables Configuration follows a simple rule: the values in `appsettings.json` are **development placeholders**, and every secret or environment-specific value is overridden by an environment variable in production. There is no Key Vault or Secrets Manager scaffolding to adopt; wire your platform's secret mechanism to environment variables and you are done. Typical overrides: - `ConnectionStrings__Postgres` - `ConnectionStrings__Redis` - `RabbitMq__*`, `Storage__*` (MinIO/S3) - `Cors__AllowedOrigins` - OTLP exporter endpoint for your collector ## Database migrations EF Core migrations are applied automatically on start in development. For production, run them as an explicit step in your deploy pipeline so a rollout never races the schema: ```sh dotnet ef database update \ --project api/src/Slicekit.Core \ --startup-project api/src/Slicekit.Api \ --context AppDbContext ``` ## Behind a reverse proxy The API trusts forwarded headers, so terminate TLS at your proxy or load balancer and forward the scheme and client address. This keeps secure-cookie flags, redirects and audited IP addresses correct. ## CI GitHub Actions builds, tests and lints both sides on every push. Use the same workflows as the gate for your deploys: a green build is a deployable build. ## Adapting it Slicekit does not prescribe a host. The images are standard, the configuration is environment variables, and the observability exporters are OTLP, so point them at your infrastructure and ship. # Configuration > How the template is configured: appsettings as development placeholders, environment-variable overrides in production, and the local port map. ## One rule: placeholders in, environment variables out Configuration follows a single principle. The values committed to `appsettings.json` are **development placeholders** that let you clone and run the stack with `docker compose up -d` and no extra setup. Every secret or environment-specific value is **overridden by an environment variable** in any real environment. There is no Key Vault or Secrets Manager scaffolding to adopt. Point your platform's secret mechanism (Docker secrets, Kubernetes `Secret`, your host's env panel) at environment variables and you are done. Nothing in the repo needs to change to deploy. This page covers the API and SPA. The marketing site has its own customization guide: see [customising the landing site](/docs/landing-site). ## The double-underscore convention .NET maps environment variables onto the configuration tree by replacing each `:` with `__` (two underscores). A nested key in `appsettings.json` becomes a flat environment variable: ```json { "ConnectionStrings": { "Postgres": "Host=localhost;..." }, "Cors": { "AllowedOrigins": ["http://localhost:3003"] } } ``` ```sh ConnectionStrings__Postgres="Host=db;Port=5432;Database=slicekit;Username=...;Password=..." Cors__AllowedOrigins__0="https://app.example.com" ``` Array entries are addressed by index (`__0`, `__1`). This is the standard ASP.NET Core binding, so anything you can put in `appsettings.json` you can override from the environment without touching code. Strongly-typed settings are bound the same way, see [the settings pattern](/docs/settings-pattern). ## Common overrides The values you will set in production, grouped by concern: | Concern | Variables | | -------------- | ------------------------------------------------------------------------- | | Database | `ConnectionStrings__Postgres` | | Cache/sessions | `ConnectionStrings__Redis` | | Messaging | `RabbitMq__Host`, `RabbitMq__Username`, `RabbitMq__Password` | | File storage | `Storage__Endpoint`, `Storage__AccessKey`, `Storage__SecretKey`, `Storage__Bucket` | | CORS | `Cors__AllowedOrigins__0`, `Cors__AllowedOrigins__1`, ... | | OAuth | `Authentication__Google__ClientId`, `Authentication__Google__ClientSecret` | | Telemetry | `OTEL_EXPORTER_OTLP_ENDPOINT` (the OTLP collector your traces and audit log flow to) | Storage points at MinIO locally and any S3-compatible bucket in production (see [file storage](/docs/file-storage)). OAuth client credentials are added per provider (see [adding an OAuth provider](/docs/oauth-provider)). Telemetry exporters are plain OTLP, so they target your collector (see [observability](/docs/observability)). ## The local port map `docker compose up -d` brings up the full local stack. The dev servers and infrastructure use a fixed set of ports: | Service | Port | Notes | | ------------ | ------------ | ---------------------------------------------- | | Frontend | 3003 | Vite dev server; matches `Cors:AllowedOrigins` | | API | 5076 / 5077 | http / https from Kestrel | | Postgres | 5432 | | | Redis | 6379 | | | RabbitMQ | 5672 / 15672 | broker / management UI | | Mailpit | 1025 / 8025 | SMTP / web UI | | MinIO | 9000 / 9001 | S3 API / console | | Grafana | 3010 | dashboards | | Prometheus | 9090 | | | Loki | 3100 | logs and the audit trail | | Tempo | 3200 | traces | | Alertmanager | 9093 | | The frontend dev server on `3003` is the origin allowed by `Cors:AllowedOrigins`, so the SPA and API agree out of the box. If you change the frontend port, update that origin. ## Production In production you run the same images with environment variables supplied by your platform: ```sh docker compose -f docker-compose.prod.yml up -d --build ``` Terminate TLS at your proxy or load balancer and forward the scheme and client address; the API trusts forwarded headers so cookies, redirects and audited IPs stay correct. See [reverse proxy](/docs/reverse-proxy) and [deployment](/docs/deployment) for the full topology. ## Checklist - Treat `appsettings.json` as placeholders; never commit a real secret. - Override secrets and environment-specific values with environment variables, using `__` for `:`. - Set `Cors__AllowedOrigins` to your real frontend origin(s). - Point `OTEL_EXPORTER_OTLP_ENDPOINT` and `Storage__*` at your infrastructure. - Run migrations as an explicit deploy step (see [deployment](/docs/deployment)). # Reverse proxy > Run the API behind a reverse proxy (TLS, forwarded headers, cookies) in production. In production the API runs plain HTTP. `docker-compose.prod.yml` binds it with `ASPNETCORE_URLS=http://+:8080` and expects a TLS-terminating reverse proxy (nginx, Caddy, Traefik, an ALB, an ingress controller) in front of it. The SPA is served as static assets and does not care about any of this; see [Deployment](/docs/deployment) for the build. Without help, every request behind the proxy looks like it came from the proxy itself over HTTP: the wrong client IP and the wrong scheme. Three things break as a result: - **HTTPS redirection** loops, because `Request.Scheme` is always `http`. - **Rate limiting** buckets every caller under the proxy's single IP. - **Auditing** records the proxy's address instead of the client's. - **Secure cookies** are dropped, because the request looks insecure. ## Forwarded headers The fix is forwarded-headers middleware. `app.UseForwardedHeaders()` runs **first** in `api/src/Slicekit.Api/Program.cs`, before `app.UseHttpsRedirection()` and everything else, and rewrites `HttpContext.Connection.RemoteIpAddress` and `Request.Scheme` from the `X-Forwarded-For` and `X-Forwarded-Proto` headers the proxy sets: ```csharp app.UseForwardedHeaders(); // ... app.UseHttpsRedirection(); ``` The options are configured in `api/src/Slicekit.Api/Configuration/Api.cs`. Only `X-Forwarded-For` and `X-Forwarded-Proto` are processed: ```csharp builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; var knownProxies = builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []; var knownNetworks = builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []; // ... }); ``` ## Trusting the right hops Trust is controlled by two optional config arrays: | Key | Meaning | | -------------------------------- | ---------------------------------------- | | `ForwardedHeaders:KnownProxies` | Exact proxy IPs to trust (e.g. `10.0.0.7`) | | `ForwardedHeaders:KnownNetworks` | CIDR ranges to trust (e.g. `10.0.0.0/8`) | Both default to **empty**. That clears ASP.NET Core's loopback defaults and makes the middleware trust the **immediate** forwarder regardless of address. This is the correct, simplest setup when exactly one proxy sits in front of the API. Populate either array to restrict trust to known hops. Do this when the proxy is reachable from untrusted networks, so a client cannot spoof `X-Forwarded-For`. Override them with environment variables, using the `__` separator and a `__0`, `__1` index for array entries (see [Configuration](/docs/configuration)): ```bash ForwardedHeaders__KnownNetworks__0=10.0.0.0/8 ForwardedHeaders__KnownProxies__0=10.0.0.7 ``` ### Chained proxies The default `ForwardLimit` is **1**: one forwarded hop is consumed. If two proxies sit in front of the API (a CDN in front of an ingress, say), raise it so the real client IP at the far end is read: ```csharp options.ForwardLimit = 2; ``` Set it to exactly the number of trusted hops, never higher. Each extra hop is one more `X-Forwarded-For` entry a client could forge. ## TLS termination Terminate TLS at the proxy and forward plain HTTP to the API on port `8080`. The proxy is responsible for the certificate, HTTP-to-HTTPS redirects at the edge, and setting `X-Forwarded-Proto: https` so the API knows the original request was secure. ## Cookies and CSRF This matters most for authentication. The auth cookies in `api/src/Slicekit.Api/Auth/CookieHelper.cs` are issued with `Secure = true` outside development and `SameSite=Strict`, and the session cookie in `Configuration/Auth.cs` uses `CookieSecurePolicy.Always` in production. A browser will only return a `Secure` cookie over HTTPS, and the SPA and API share an origin, so the SameSite policy is the CSRF defence rather than a header check. Forwarded headers are what make this work behind the proxy. With `X-Forwarded-Proto: https` propagated and trusted, the request is treated as secure, cookies are accepted, and HTTPS redirection does not loop. Get the forwarded headers wrong and login silently fails: the cookie is set but the browser refuses to store it. See [Authentication](/docs/authentication) for the cookie and CSRF model. ## Sample nginx config ```nginx server { listen 443 ssl; server_name app.example.com; ssl_certificate /etc/ssl/certs/app.example.com.pem; ssl_certificate_key /etc/ssl/private/app.example.com.key; location / { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } server { listen 80; server_name app.example.com; return 301 https://$host$request_uri; } ``` `$proxy_add_x_forwarded_for` appends the client to any existing chain, and `$scheme` is `https` here, so the API sees the real client IP and the original scheme. ## Checklist - [ ] Proxy terminates TLS and forwards plain HTTP to the API on `8080`. - [ ] Proxy sets `X-Forwarded-For` and `X-Forwarded-Proto` on every request. - [ ] One proxy in front: leave `KnownProxies` and `KnownNetworks` empty. - [ ] Proxy reachable from untrusted networks: populate `KnownProxies` or `KnownNetworks`. - [ ] More than one hop: set `ForwardLimit` to the exact number of trusted hops. - [ ] Log in over HTTPS and confirm the auth cookies are stored, not dropped. - [ ] Hit an audited endpoint and confirm the recorded IP is the client's, not the proxy's. - [ ] Exhaust an IP-partitioned rate limit from one client and confirm a second client is not throttled. --- # Landing site # Overview > The marketing site in landing/: a static Astro site with no React or runtime, and the map of files you change to make it your own. The marketing site in `landing/` is a static [Astro](https://astro.build) site with Tailwind v4. There is no React and no runtime: everything renders to static HTML at build time. To make the template your own you change copy and colours in a handful of known files, run the build, and ship the output. These pages are the map of those files. ## Where things live | You want to change… | Go to | Page | | ------------------------------------------ | ------------------------------------------------ | --------------------------------------- | | Brand copy, URLs, pricing, SEO defaults | `src/config/site.ts` | [Brand and navigation](/docs/landing-site-brand) | | Header navigation and footer links | `src/config/navigation.ts` | [Brand and navigation](/docs/landing-site-brand) | | Colours, design tokens, fonts, dark mode | `src/styles/global.css`, `src/layouts/Layout.astro` | [Theming and dark mode](/docs/landing-site-theming) | | The logo, favicon and Open Graph mark | logo, favicon, `scripts/generate-og.mjs` | [Theming and dark mode](/docs/landing-site-theming) | | Blog posts and documentation pages | `src/content/blog/`, `src/content/docs/` | [Content and assets](/docs/landing-site-content) | | Product screenshots and Open Graph images | `scripts/capture-screenshots.mjs`, `generate-og.mjs` | [Content and assets](/docs/landing-site-content) | ## Develop and build Run everything from the `landing/` directory: | Goal | Command | | ---------------- | -------------- | | Dev server | `pnpm dev` | | Production build | `pnpm build` | | Preview build | `pnpm preview` | `pnpm build` regenerates the Open Graph images (via the `build:og` step) and then renders the whole site to static HTML in `dist/`. Deploy that directory to any static host. One rule keeps dark mode working as you customise: prefer the semantic Tailwind utilities (`bg-surface`, `text-muted`, `text-accent`) over hard-coded colours, so a change to the palette flows to both themes. The pages that follow explain each area in turn. # Brand and navigation > Change brand copy, URLs, pricing and SEO defaults in src/config/site.ts, and edit the header and footer links in src/config/navigation.ts. ## Brand, copy and links `src/config/site.ts` is the single source of truth for brand copy, URLs and SEO defaults. It is imported by `astro.config.mjs`, the layout and the components, so changing a value here flows everywhere. Edit it here, not inline in components. The exported `siteConfig` object is grouped by concern: | Group | What it holds | | ---------- | ----------------------------------------------------------------------------------- | | `company` | `name`, `legalName`, contact `email` | | `author` | The human behind the product (name, title, URL, initials); feeds the schema.org graph | | `links` | `appUrl` (live demo SPA), `apiUrl` (API host), `repoUrl` (source), `buyUrl` (checkout) | | `pricing` | `amount`, `currency`, `display`, `license`, `guarantee`; flows to the CTAs and pricing card | | `site` | `name`, `title`, `description`, `url`, `ogImage`, `keywords` | | `seo` | Default `title`, `description` and `twitterCard` used as a fallback on every page | Two values you will almost certainly change: point `links.buyUrl` at your payment provider's hosted checkout (Stripe, Lemon Squeezy, Paddle), and set `pricing` to your terms. The price is read in one place and reused by every CTA and the schema.org Offer. ## Navigation and footer Both menus are defined in `src/config/navigation.ts`: `mainNav` is the header links and `footerNav` is the footer's grouped columns. `Header.astro` and `Footer.astro` import and render those arrays, so you edit links in one place rather than in the markup. Links that point at the product (the demo, the API reference, GitHub, the contact email) are built from `siteConfig`, so updating a value in `site.ts` updates them too. The mobile menu is driven entirely by the CSS `:target` selector, so there is no JavaScript to touch when you add or remove a link. ## Checklist - [ ] Change brand copy, links and pricing in `src/config/site.ts`, not inline in components. - [ ] Point `links.buyUrl` at your hosted checkout and set `pricing` to your commercial terms. - [ ] Add or remove header and footer links in `src/config/navigation.ts`; no JavaScript change is needed. # Theming and dark mode > Recolour the site through the design tokens in global.css, swap fonts, and understand the class-based dark mode and the shared vertex mark. ## Colours and design tokens All colours and design tokens live in `src/styles/global.css`. The colour system has two layers, and the distinction matters: 1. **Raw palette values** are defined in `:root` (light) and overridden in `.dark` (dark): `--bg`, `--surface`, `--surface-alt`, `--fg`, `--heading`, `--muted`, `--border`, `--accent`, `--accent-hover`, `--accent-fg`, `--accent-soft`, `--grid-line` and `--grid-line-major`. The accent moves from teal (`#0e7490`) in light to cyan (`#22d3ee`) in dark. **To recolour the site, edit these raw values.** 2. **Theme tokens** in the `@theme` block map onto the raw values (for example `--color-accent: var(--accent)`). Tailwind turns these into the semantic utilities you use in markup: `bg-surface`, `text-muted`, `text-accent`, `border-border`, and so on. Always reach for the semantic utilities in markup and never hard-code a colour. Because the utilities resolve through the raw variables, and the raw variables flip under `.dark`, dark mode keeps working for free. The same file also defines the typography tokens (`--font-display`, `--font-sans`, `--font-mono`), section spacing (`--spacing-section`) and card elevation (`--shadow-card`, `--shadow-card-hover`). ## Fonts The three fonts are self-hosted through `@fontsource-variable/*` packages, so the site makes no external font requests. They are imported at the top of `src/styles/global.css` and wired to the typography tokens: | Token | Font | Used for | | ----------------- | --------------- | ------------------ | | `--font-display` | Space Grotesk | Headings | | `--font-sans` | IBM Plex Sans | Body text | | `--font-mono` | JetBrains Mono | Kickers and labels | To swap a font: install the matching `@fontsource-variable/` package, add an `@import` for it in `global.css`, and repoint the relevant `--font-*` token at the new family. ## Dark mode Dark mode is class-based: the `.dark` class on `` swaps the raw palette. The choice is persisted under the `slicekit.theme` localStorage key, which is shared with the product SPA so a visitor's theme carries across. An inline no-flash script in `src/layouts/Layout.astro` applies the stored theme before the first paint, so there is no flash of the wrong theme. Shiki code blocks switch to their dark variables under `.dark` as well. ## The vertex mark The logo, the favicon and the `mark()` function in `scripts/generate-og.mjs` all draw the same vertex mark. If you rebrand the logo, change all three together so the favicon and generated Open Graph images stay consistent with the site. ## Checklist - [ ] Recolour by editing the raw `--*` variables in `:root` and `.dark`; keep using the semantic utilities (`bg-surface`, `text-accent`) in markup so dark mode keeps working. - [ ] Swap a font by installing its `@fontsource-variable` package, importing it in `global.css` and repointing the `--font-*` token. - [ ] Update the logo, favicon and the `mark()` in `scripts/generate-og.mjs` together. # Content and assets > Add blog posts and documentation pages through Astro content collections, refresh product screenshots, and regenerate Open Graph images. ## Content collections Blog posts and documentation pages are Astro content collections defined in `src/content.config.ts`. Adding a Markdown file to a collection is enough: the sidebar, search index, prev/next navigation and the raw `/docs/.md` route are all derived from the collection. - **Blog**: add `src/content/blog/.md` with frontmatter `title`, `description`, `publishDate` and optional `updatedDate`, `author`, `tags`, `draft`, `ogImage`. Set `draft: true` to keep a post out of the production build. - **Docs**: add `src/content/docs/.md` with frontmatter `title`, `description`, `section` and `order`. Pages are grouped by `section` and sorted by `order`; a section appears in the sidebar at the position of its lowest-ordered page (these Landing site pages sit in the `Landing site` section). ## Screenshots and the product tour The product screenshots in `public/screenshots/` are real captures from the running app, taken by `scripts/capture-screenshots.mjs` (its header documents the seed and capture flow). Re-run that script after a UI change rather than editing the images by hand. `ScreenshotFrame.astro` renders each capture in both theme variants, and `ProductTour.astro` is the tabbed gallery that presents them. ## Open Graph images Open Graph images are generated at build time. The `build:og` script (`scripts/generate-og.mjs`) runs automatically before `astro build` when you run `pnpm build`, producing the default `public/og-image.png` plus a per-page image under `public/og/...` for each page, blog post and docs page. There is no manual step: build the site and the images are regenerated. ## Checklist - [ ] Add content by dropping a Markdown file into `src/content/blog/` or `src/content/docs/` with the right frontmatter. - [ ] Re-run `scripts/capture-screenshots.mjs` after UI changes; do not hand-edit the screenshots. - [ ] Run `pnpm build` to produce the static site and regenerate every Open Graph image.