Plans the Gitea deployment feature and refreshes the codebase analysis

Adds the AI-DLC inception record for deploying the CMS as a single .NET
application on hosting where no server configuration is possible.

The reverse-engineering artifacts were regenerated: the previous set
predated the Master module, the Slave host, the solution reorganisation
and single-host serving, all of which matter for deployment. Findings
were verified by running the build, both test suites and the linter
rather than inferred, which surfaced two facts the plan depends on:
the frontend lint gate currently fails (5 errors), and two transitive
packages carry high-severity advisories.

Records 24 functional requirements, 32 traced decisions and a
seven-unit decomposition whose ordering is load-bearing: durability
work must land before the first automated deploy, or the very first
deploy is the one that silently breaks master/slave trust.

Two conflicts found while designing and carried into the units:
- Both modules call AddDataProtection(), which runs after the host and
  would override a persistent key store while still passing any
  registration test.
- The availability gate runs before authentication, so its admin
  bypass cannot read HttpContext.User.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-27 23:59:30 +02:00
co-authored by Claude Opus 5
parent 38857038a0
commit 8568ca43c6
25 changed files with 4233 additions and 503 deletions
@@ -1,134 +1,257 @@
# API Documentation
# API Documentation
## Conventions
- **Global prefix**: every controller is served under `api/v1`, applied centrally by `ApiPrefixConvention` in `Program.cs` — controllers themselves declare only their own route segment. API version reporting is enabled via `Asp.Versioning.Mvc`.
- **Authentication**: JWT bearer (`Authorization: Bearer <accessToken>`). Token validation uses `ClockSkew.Zero`, so expiry is exact.
- **Refresh token**: returned as an httpOnly cookie named `refreshToken`, `SameSite=Strict` (`None` in Development), scoped to path `/api/v1/auth`. It is deliberately `[JsonIgnore]`d out of the response body.
- **Authorization policies**: `OwnerOnly`, `AdminOnly`, `UserOnly` — hierarchical, so a higher role satisfies a lower requirement.
- **Errors**: RFC 9457 `ProblemDetails` via `GlobalExceptionHandler`.
- **Enums**: serialized as strings (`JsonStringEnumConverter`).
- **Rate limiting**: named limiters `login` (fixed window, default 5/60s) and `refresh` (sliding window, default 20/60s, 4 segments); rejections return `429`.
- **Availability gate**: unless a path is on the bypass list or the caller presents an Owner/Administrator token, requests are blocked with `503 ProblemDetails` when the master gate or the local status says unavailable. Bypass prefixes: `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus`.
- **API reference UI**: Scalar at `/scalar` and the OpenAPI document are mapped **in Development only**.
## Non-API routes served by the same host
| Path | Behaviour |
|---|---|
| `/` and `/{*path:nonfile}` | Public website from `wwwroot/`, with SPA fallback to `wwwroot/index.html`. Served by static-file middleware **before** the availability gate is installed. |
| `/admin` and `/admin/{*path:nonfile}` | Admin SPA from `wwwroot/admin/`, fallback to `wwwroot/admin/index.html`. |
| Any path with a file extension that does not exist | Returns `404` — the `nonfile` route constraint deliberately excludes it from the SPA fallbacks. |
## REST APIs
### Authentication
### Authentication — `Modules.Identity/AuthController` (`/api/v1/auth`)
#### POST /auth/login
#### Login
- **Method**: POST
- **Path**: `/auth/login`
- **Purpose**: Authenticate a user and receive JWT tokens
- **Authorization**: Anonymous
- **Request**: `{ "email": string, "password": string }`
- **Response**: `{ "accessToken": string, "expiresAt": datetime, "user": { "id": guid, "email": string, "name": string, "role": string, "isActive": bool } }`
- **Cookie set**: `refreshToken` (httpOnly, Secure, SameSite=Strict, Path=/api/v1/auth)
- **Path**: `/api/v1/auth/login`
- **Purpose**: Authenticate a user and start a session.
- **Auth**: Anonymous. Rate limiter `login`.
- **Request**: `LoginRequest { email, password }`
- **Response**: `200` `TokenResponse { accessToken, expiresAt, user { id, email, name, role, isActive } }` plus a `refreshToken` cookie.
#### POST /auth/refresh
#### Refresh
- **Method**: POST
- **Path**: `/auth/refresh`
- **Purpose**: Refresh an access token using the httpOnly refresh token cookie
- **Authorization**: Anonymous
- **Request**: (empty body refresh token read from cookie)
- **Response**: Same as `/auth/login` (new access token + new cookie)
- **Path**: `/api/v1/auth/refresh`
- **Purpose**: Rotate the refresh token and issue a new access token (used for silent refresh on SPA startup).
- **Auth**: Anonymous — authority comes from the cookie. Rate limiter `refresh`.
- **Request**: No body; reads the `refreshToken` cookie.
- **Response**: `200` `TokenResponse` plus a replaced cookie; `401` when the cookie is missing or invalid.
#### POST /auth/revoke
#### Revoke
- **Method**: POST
- **Path**: `/auth/revoke`
- **Purpose**: Revoke a refresh token (logout)
- **Authorization**: Bearer JWT required
- **Request**: `"<refreshToken>"` (string body)
- **Response**: 204 No Content
- **Path**: `/api/v1/auth/revoke`
- **Purpose**: Log out — revoke the refresh token and clear the cookie.
- **Auth**: Anonymous (the cookie carries the authority).
- **Response**: `200`.
---
#### Change password
- **Method**: POST
- **Path**: `/api/v1/auth/change-password`
- **Purpose**: Replace the caller's own password.
- **Auth**: Any authenticated user.
- **Request**: `ChangePasswordRequest { currentPassword, newPassword }`
- **Response**: `200`, or `ProblemDetails` on validation failure.
### Setup
### Setup — `Modules.Identity/SetupController` (`/api/v1/Setup`)
#### GET /setup/status
#### Get setup status
- **Method**: GET
- **Path**: `/setup/status`
- **Purpose**: Check if the system has been initialized (first owner created)
- **Authorization**: Anonymous
- **Response**: `{ "initialized": boolean }`
- **Path**: `/api/v1/Setup/status`
- **Purpose**: Tell a client whether the system still needs bootstrapping. On the availability bypass list.
- **Auth**: Anonymous.
- **Response**: `200` `{ initialized: bool }`.
#### POST /setup/owner
#### Create initial owner
- **Method**: POST
- **Path**: `/setup/owner`
- **Purpose**: Create the initial Owner account (only usable when system is not yet initialized)
- **Authorization**: Anonymous
- **Request**: `{ "email": string, "password": string }`
- **Response**: `{ "message": string }`
- **Path**: `/api/v1/Setup/owner`
- **Purpose**: One-time creation of the first Owner account.
- **Auth**: Anonymous (only meaningful while uninitialized).
- **Request**: `CreateOwnerRequest { name, email, password }`
- **Response**: `200` `{ message }`.
---
### Invitations — `Modules.Identity/InvitationController` (`/api/v1/Invitation`)
### Users
#### POST /users/invite
- **Method**: POST
- **Path**: `/users/invite`
- **Purpose**: Invite a new user by email with a specified role
- **Authorization**: Bearer JWT, Policy: AdminOnly
- **Request**: `{ "email": string, "role": string }`
- **Response**: `{ "inviteLink": string }`
#### POST /users/complete-setup
- **Method**: POST
- **Path**: `/users/complete-setup`
- **Purpose**: Complete account setup using an invitation token
- **Authorization**: Anonymous
- **Request**: `{ "token": string, "password": string }`
- **Response**: `{ "message": string }`
#### GET /users/validate-invitation
#### Validate invitation
- **Method**: GET
- **Path**: `/users/validate-invitation?token={token}`
- **Purpose**: Validate an invitation token before showing the setup form
- **Authorization**: Anonymous
- **Response**: `{ "valid": boolean, "email": string, "role": string }` or error
- **Path**: `/api/v1/Invitation/validate?token={token}`
- **Purpose**: Check an invitation token before showing the registration form.
- **Auth**: Anonymous.
- **Response**: `200` `{ isValid, email, name, errorCode }` — an invalid token is reported in the body (e.g. `errorCode: "NOT_FOUND"`), not as an error status.
---
### Availability
#### GET /availability/status
- **Method**: GET
- **Path**: `/availability/status`
- **Purpose**: Get current system availability status
- **Authorization**: Anonymous
- **Response**: `{ "status": "Available|Maintenance|Unavailable", "checkedAt": datetime, "message": string }`
#### POST /availability/admin/status
#### Complete invitation
- **Method**: POST
- **Path**: `/availability/admin/status`
- **Purpose**: Update the system availability status
- **Authorization**: Bearer JWT, Policy: OwnerOnly
- **Request**: `{ "newStatus": "Available|Maintenance|Unavailable", "reason": string }`
- **Response**: 200 OK or 400 Bad Request
- **Path**: `/api/v1/Invitation/complete`
- **Purpose**: Set a password and activate the invited account.
- **Auth**: Anonymous.
- **Request**: `CompleteSetupRequest { token, password }`
- **Response**: `200` `{ message }`.
---
### Users — `Modules.Identity/UsersController` (`/api/v1/Users`)
## Authorization Policies
Controller default policy: `AdminOnly`.
| Policy | Required Role | Description |
|--------|--------------|-------------|
| `OwnerOnly` | Owner | Full system access including availability management |
| `AdminOnly` | Owner or Admin | User management access |
| Method | Path | Purpose | Auth | Request | Response |
|---|---|---|---|---|---|
| GET | `/api/v1/Users` | List users, including pending invitations | AdminOnly | — | `200` `UserDto[]` |
| PUT | `/api/v1/Users/me` | Update the caller's own profile | Any authenticated | `UpdateProfileRequest { name, email }` | `200` |
| POST | `/api/v1/Users/invite` | Invite a user and get an invite link | AdminOnly | `InviteUserRequest { email, role }` | `200` `{ token, inviteLink }`, link shaped `/invite/complete?token=…` |
| PUT | `/api/v1/Users/{userId:guid}/role` | Change a user's role | AdminOnly, hierarchy enforced | `ChangeRoleRequest { newRole }` | `200`, `404` if unknown |
| PUT | `/api/v1/Users/{userId:guid}/active` | Activate or deactivate a user | AdminOnly | `SetUserActiveRequest { isActive }` | `200`, `404` if unknown |
| DELETE | `/api/v1/Users/{userId:guid}` | Delete a user | AdminOnly | — | `200`, `404` if unknown |
`UserDto { id, email, name, role, isActive, createdAt, invitationPending, inviteLink? }`
### Availability — `Modules.Availability/AvailabilityController` (`/api/v1/Availability`)
#### Get status
- **Method**: GET
- **Path**: `/api/v1/Availability/status`
- **Purpose**: Report this instance's availability. On the bypass list, so it answers even while the instance is gated off — the most useful existing endpoint for external monitoring.
- **Auth**: Anonymous.
- **Response**: `200` `{ status: "Available" | "NotAvailable" | "Maintenance" | "Degraded" | "Unknown", checkedAt, message, isMasterControlled }`.
#### Update status
- **Method**: POST
- **Path**: `/api/v1/Availability/admin/status`
- **Purpose**: Owner switches the local availability status.
- **Auth**: `OwnerOnly`.
- **Request**: `UpdateStatusRequest { newStatus, reason }`
- **Response**: `200`; `409 ProblemDetails` when the Master controls this instance's status (`MasterControlledAvailabilityException`); `400` if the active availability service does not support updates.
### Master-side inbound endpoints on a slave — `Modules.Availability/MasterController` (`/api/v1/master`)
All three authenticate with the `X-Master-Api-Key` header rather than JWT, and are on the availability bypass list so a Master can always reach a gated-off slave.
| Method | Path | Purpose | Request | Response |
|---|---|---|---|---|
| POST | `/api/v1/master/register` | Master registers itself with this instance | `RegisterMasterRequest { masterUrl }` + `X-Master-Api-Key` | `200`, `401` without the key |
| POST | `/api/v1/master/status` | Master pushes this instance's status | `PushStatusRequest { isAvailable, disableMessage? }` + `X-Master-Api-Key` | `200`, `401` without the key |
| GET | `/api/v1/master/registered-url` | Report which Master this instance is bound to | `X-Master-Api-Key` | `200`, `401` without the key |
### CMS instance management on the Master — `Modules.Master/CmsInstanceController` (`/api/v1/CmsInstances`)
Controller policy: `OwnerOnly`. Present only on instances that ship the Master module.
| Method | Path | Purpose | Request | Response |
|---|---|---|---|---|
| GET | `/api/v1/CmsInstances` | List registered instances | — | `200` `CmsInstanceDto[]` |
| POST | `/api/v1/CmsInstances` | Register an instance and push the registration to it | `CreateCmsInstanceRequest { name, url, apiKey }` | `200`, error `ProblemDetails` on failure |
| PUT | `/api/v1/CmsInstances/{id:guid}/status` | Set an instance's status and push it | `UpdateStatusRequest { status, disableMessage? }` | `200` `UpdateStatusResult { success, slaveContactSuccess }` |
`CmsInstanceDto { id, name, url, status, disableMessage?, lastContactedAt?, lastStatusPushedAt?, lastIntegrityCheckFailedAt? }`
`CmsInstanceStatus`: `Available` (0), `NotAvailable` (1), `Inactive` (2).
`UpdateStatusResult` deliberately separates "the Master recorded it" from "the slave acknowledged it" — a status change can succeed locally while the push fails, which the periodic integrity check later repairs.
### Slave status poll on the Master — `Modules.Master/SlaveStatusController` (`/api/v1/SlaveStatus`)
- **Method**: GET
- **Path**: `/api/v1/SlaveStatus`
- **Purpose**: Lets a slave pull its own authoritative status from the Master. This is the guard against local tampering and missed pushes.
- **Auth**: `[AllowAnonymous]` at the JWT level; authenticated by `X-Master-Api-Key`. On the availability bypass list.
- **Response**: `200` with the caller's status, `401` without the key.
### System — `Core/Hosting/SystemController` (`/api/v1/System`)
#### Get capabilities
- **Method**: GET
- **Path**: `/api/v1/System/capabilities`
- **Purpose**: Report which optional modules are loaded, so a client can hide features this deployment does not have rather than interpreting a 404.
- **Auth**: Anonymous. **Not** on the availability bypass list, so it returns `503` while the instance is gated off.
- **Response**: `200` `{ modules: string[] }` — e.g. `["Identity","Availability","Master"]` on a Master, `["Identity","Availability"]` on a slave.
## Observability endpoints
**None exist.** There is no `MapHealthChecks`, no `/health`, `/healthz` and no readiness or liveness endpoint anywhere in the solution. A dedicated health-check endpoint therefore has to be built before external uptime monitoring can be wired up meaningfully.
**`/api/v1/Availability/status` and `/api/v1/System/capabilities` are not health checks** and must not be repurposed as such. Both are CMS domain functionality:
- **Availability** is the product's own on/off state — the local maintenance switch plus the master gate. It answers the business question "should this site currently serve visitors?", which is deliberately independent of whether the application is healthy. A perfectly healthy instance reports `NotAvailable` when an Owner or its Master has switched it off, and a sick instance can still report `Available`.
- **Capabilities** reports which modules are loaded, so a client can hide features this deployment does not have. It says nothing about whether those modules are functioning.
Both also serve the master↔slave protocol rather than operations. Conflating either with health monitoring would produce alerts on intentional business state and silence on genuine outages.
A health check is a separate concern. It needs its own endpoint, deliberately outside `/api/v1` domain routing and outside the availability gate, reporting on infrastructure liveness (process up, database reachable, migrations applied) rather than on product state.
**In scope for the `gitea-deployment-workflow` feature** (decided 2026-07-27), because ASP.NET Core provides this out of the box:
- `builder.Services.AddHealthChecks()` and `app.MapHealthChecks("/health")` need **no package at all** — both live in the shared framework. Default output is plain text `Healthy` with `200` or `Unhealthy` with `503`, which is exactly what an HTTP-probe monitor consumes.
- Adding a database probe costs one package, `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` **10.0.9** (in line with the rest of the 10.0.x dependencies), and one call: `.AddDbContextCheck<ApplicationDbContext>()`. It performs `CanConnectAsync` by default and can optionally report pending migrations — worth enabling here, since `ApplicationDbContext` is never migrated automatically.
**Required placement detail specific to this codebase**: `MapHealthChecks` registers an *endpoint*, and middleware runs before endpoints — so `AvailabilityMiddleware` would return `503` for `/health` on any instance that is switched off, reproducing exactly the conflation this section warns against. `/health` must therefore be added to `AvailabilityMiddleware._bypassPrefixes`, alongside the master endpoints. With that in place the separation stays clean: `/health` reports infrastructure, availability reports product state.
## Internal APIs
### `IModule` (`Core/Modules/IModule.cs`)
- **Methods**: `string Name { get; }`, `string Version { get; }`, `void RegisterServices(IServiceCollection services)`, `void UseModule(IApplicationBuilder app)`
- **Purpose**: The contract every module implements. `UseModule` is also where `Modules.Availability` and `Modules.Master` run `Database.Migrate()` for their own contexts.
### `ModuleOrchestrator` (`Core/Hosting/ModuleOrchestrator.cs`)
- **Methods**: `IReadOnlyList<string> ModuleNames { get; }`, `DiscoverModules()`, `RegisterModuleServices(IServiceCollection)`, `UseModules(IApplicationBuilder)`
- **Behaviour**: `DiscoverModules` globs `SlpModularCms.Modules.*.dll` in `AppDomain.CurrentDomain.BaseDirectory` and instantiates every concrete `IModule`. Load and instantiation failures are logged, not rethrown — a broken module degrades capability silently rather than failing startup.
### `IAvailabilityService` (`Core/Availability`)
- **Methods**: `IsAvailableAsync()`, `GetStatusDetailsAsync()`, and on `PersistentAvailabilityService` also `UpdateStatusAsync(status, reason, updatedBy)`
- **Returns**: `AvailabilityStatus` / `AvailabilityStatusDetails { Status, Message?, IsMasterControlled }`
- **Validation**: throws `MasterControlledAvailabilityException` when a local update is attempted while master-controlled.
### `IMasterAvailabilityService` (`Modules.Availability/Services`)
- **Methods**: `GetMasterStatus()``MasterGateStatus { IsAvailable, DisableMessage? }`, plus registration and push handling.
- **Purpose**: Supplies the master gate its verdict, including the fail-open decision when the Master has been unreachable beyond `MasterPolling:FailOpenAfterMinutes`.
### `ISlaveApiClient` (`Modules.Master/Services`)
- **Purpose**: Outbound HTTP to slaves (register, push status). Wrapped in a `slave-resilience` pipeline: 2 retries, exponential backoff with jitter, timeout from `MasterModule:HttpTimeoutSeconds`.
### `IApiKeyProtector` / `IMasterApiKeyProtector`
- **Purpose**: Encrypt and decrypt slave API keys using ASP.NET Core Data Protection. Both rely on the default file-system key ring; no persistent store is configured.
### `IAuthService`, `IInvitationService`, `ISetupService` (`Core/Identity/Services`)
- **Purpose**: Authentication with refresh-token rotation, invitation lifecycle, and first-Owner bootstrap respectively.
### Frontend `ApiClient` (`frontend/src/lib/api-client.ts`)
- **Purpose**: Single `fetch` wrapper for the SPA. Always sends credentials so the refresh cookie travels; holds the access token in memory only; on `401` runs a refresh-and-retry once; throws `ProblemDetailsError` for non-2xx and `NetworkError` for transport failures.
- **Base URL**: `VITE_API_BASE_URL`, validated as an absolute URL by `frontend/src/lib/config.ts`.
## Data Models
### AuthResponse
- `accessToken` — short-lived JWT (e.g. 15 min)
- `refreshToken` — long-lived opaque token
- `expiresAt` — access token expiry datetime
- `user` — authenticated user info
### `ApplicationUser` (extends `IdentityUser<Guid>`)
- **Fields**: identity fields plus `Name`, `IsActive`, `CreatedAt`.
- **Relationships**: roles via Identity; `RefreshToken`s; `Invitation`s.
### Password Validation Rules (enforced by backend)
Configured in `ServiceCollectionExtensions.cs` via ASP.NET Core Identity `PasswordOptions`:
- `RequiredLength = 8` — minimum 8 characters
- `RequireUppercase = true` — at least 1 uppercase letter
- `RequireLowercase = true` — at least 1 lowercase letter
- `RequireDigit = true` — at least 1 digit
- `RequireNonAlphanumeric = true` — at least 1 non-alphanumeric character (e.g. `!@#$%^&*`)
### `ApplicationRole` (extends `IdentityRole<Guid>`)
- **Fields**: identity role fields. Roles in use: `Owner`, `Administrator`, `User`.
### ApplicationUser (returned in auth responses)
- `id` — Guid
- `email` — string
- `name` — string (display name)
- `role` — string (Owner / Admin / User)
- `isActive` — boolean
### `RefreshToken`
- **Fields**: token value, expiry, revocation state, owning user.
- **Validation**: rotated on every refresh; the previous token is revoked.
### Invitation
- `token` — string (URL-safe token)
- `email` — string
- `role` — string
- `expiryDate` — datetime
- `isUsed` — boolean
### `Invitation`
- **Fields**: token, target email, role, expiry, used flag.
- **Validation**: single-use and time-limited; `InvitationOrUserAlreadyExistsException` guards duplicates.
### `ModulePermission`
- **Fields**: links a role or user to a module's permission. Stored in `ApplicationDbContext`.
### `GlobalAvailabilityState`
- **Fields**: current `AvailabilityStatus`, optional message, last-updated metadata.
- **Notes**: single-row state read by `PersistentAvailabilityService` behind a short cache and circuit breaker (`Availability:StatusCacheSeconds`, `Availability:CircuitBreakerSeconds`).
### `MasterRegistration` (`AvailabilityDbContext`)
- **Fields**: master URL, encrypted master API key, last-known pushed status and message, `LastPolledAt`.
- **Notes**: its absence makes the master gate inert — the reason `MasterPolling` settings have no effect on an unregistered instance.
### `CmsInstance` (`MasterDbContext`)
- **Fields**: `Id`, `Name`, `Url`, `Status` (`CmsInstanceStatus`), `DisableMessage?`, encrypted API key, `LastContactedAt?`, `LastStatusPushedAt?`, `LastIntegrityCheckFailedAt?`.
- **Notes**: the API key is stored Data Protectionencrypted; losing the key ring makes it unreadable.
### Password validation rules
Enforced by ASP.NET Core Identity options in `ServiceCollectionExtensions.AddCoreInfrastructure`: minimum length 8, and at least one digit, one lowercase letter, one uppercase letter and one non-alphanumeric character.
### Configuration models
- `JwtSettings { Secret, Issuer, Audience, ExpiryMinutes, RefreshTokenExpiryDays, CookieSameSite? }` — a missing `Secret` throws at startup.
- `AvailabilityOptions { CircuitBreakerSeconds, StatusCacheSeconds }`
- `MasterModuleOptions { IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl }`
- `MasterPollingOptions { PollIntervalSeconds, FailOpenAfterMinutes, HttpTimeoutSeconds }`
@@ -1,120 +1,235 @@
# System Architecture
# System Architecture
## System Overview
SlpModularCms is a modular, ASP.NET Core-based CMS platform. The backend is structured as a monolith-with-modules: a single API host (`SlpModularCms.Api`) that dynamically loads feature modules at startup. Each module is self-contained and registers its own services and HTTP middleware. Persistence is handled via Entity Framework Core with SQL Server. Authentication uses JWT Bearer tokens with refresh token rotation.
SlpModularCms is a **modular monolith** on .NET 10. A single ASP.NET Core host process discovers feature modules from disk at startup (`ModuleOrchestrator`), lets each register its own services and middleware, and exposes every controller under one `/api/v1` prefix via a global MVC convention.
The frontend is a React SPA (to be built) that communicates with the API via REST/JSON. The example app (from ZIP) provides the design foundation: Vite + React Router v7 + shadcn/ui + Tailwind CSS v4 with primary color `#ac0000`.
The defining architectural decision for deployment is **single-host serving** (commit `3885703`): because typical shared hosting allows only one site/application pool and no server configuration, the API process itself also serves the two frontends from `wwwroot`:
| Path | Content | Origin |
|---|---|---|
| `/` | The customer's public website | Built and deployed **separately** — not part of this repository; lands in `wwwroot/` |
| `/admin` | The CMS admin SPA | Built from `frontend/` with Vite `base: '/admin/'`, copied to `wwwroot/admin/` by an MSBuild target on `dotnet publish` |
| `/api/v1/...` | The REST API | This solution |
Both frontends get their own SPA fallback so client-side routes resolve, while genuinely missing assets still return 404.
Persistence is EF Core on SQL Server. Three `DbContext` types share **one** connection string: `ApplicationDbContext` (Core/Identity), `AvailabilityDbContext` and `MasterDbContext`. The two module contexts migrate themselves at startup; the Core context does not and must be migrated explicitly.
Authentication is JWT bearer with an httpOnly, rotating refresh-token cookie. Authorization is hierarchical (Owner > Administrator > User). Errors follow RFC 9457 `ProblemDetails`.
## Architecture Diagram
```mermaid
graph TD
subgraph ClientLayer["Client Layer"]
Frontend["React SPA\nVite + TanStack Router + shadcn/ui\nTailwind CSS v4 #ac0000"]
visitor["Public visitor"]
adminuser["Admin user (browser)"]
subgraph host["SlpModularCms.Api — single host process"]
static["Static files + SPA fallbacks<br/>wwwroot/ and wwwroot/admin/"]
pipeline["Middleware pipeline<br/>exception handler, rate limiter,<br/>HTTPS redirect, CORS, availability gate, auth"]
orchestrator["ModuleOrchestrator<br/>assembly discovery"]
core["SlpModularCms.Core<br/>identity, authz, module contract,<br/>routing convention, error handling"]
modidentity["Modules.Identity<br/>auth, setup, invitations, users"]
modavail["Modules.Availability<br/>availability gate, master registration"]
modmaster["Modules.Master<br/>instance registry, status push"]
end
subgraph ApiLayer["API Layer"]
Api["SlpModularCms.Api\nASP.NET Core\nJWT Bearer, CORS, Swagger"]
Identity["Identity Module\nAuthController\nSetupController\nUsersController"]
Avail["Availability Module\nAvailabilityController\nPersistentService + CircuitBreaker"]
end
db[("SQL Server<br/>ApplicationDbContext<br/>AvailabilityDbContext<br/>MasterDbContext")]
slaveinst["Slave CMS instances<br/>(separate deployments)"]
subgraph CoreLayer["Core Layer"]
Core["SlpModularCms.Core\nApplicationDbContext\nDomain Entities\nIdentity Services\nIModule interface"]
end
visitor --> static
adminuser --> static
adminuser --> pipeline
pipeline --> orchestrator
orchestrator --> modidentity
orchestrator --> modavail
orchestrator --> modmaster
modidentity --> core
modavail --> core
modmaster --> core
core --> db
modavail --> db
modmaster --> db
modmaster -->|"HTTP push: register + status"| slaveinst
slaveinst -->|"HTTP pull: own status"| modmaster
subgraph DataLayer["Data Layer"]
DB[("SQL Server\nIdentity tables\nRefreshTokens\nInvitations\nGlobalAvailabilityState")]
end
Frontend -->|HTTP REST / JSON| Api
Api --> Identity
Api --> Avail
Identity --> Core
Avail --> Core
Core --> DB
style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff
style Api fill:#4CAF50,stroke:#2E7D32,color:#fff
style Identity fill:#4CAF50,stroke:#2E7D32,color:#fff
style Avail fill:#4CAF50,stroke:#2E7D32,color:#fff
style Core fill:#FFC107,stroke:#F57F17,color:#000
style DB fill:#FF5722,stroke:#BF360C,color:#fff
classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef surface fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
classDef external fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class visitor,adminuser actor;
class static,pipeline,orchestrator surface;
class core corelayer;
class modidentity,modavail,modmaster module;
class db store;
class slaveinst external;
```
Text alternative: One host process serves static frontends and an API; a module orchestrator loads the Identity, Availability and Master modules, which all build on Core and share one SQL Server database, while the Master module exchanges registration and status with separately deployed slave instances.
## Component Descriptions
### SlpModularCms.Api
- **Purpose**: Web API host and application entry point
- **Responsibilities**: Bootstrap, module loading, middleware pipeline, CORS, Swagger
- **Dependencies**: SlpModularCms.Core, SlpModularCms.Modules.Identity, SlpModularCms.Modules.Availability
- **Type**: Application
- **Purpose**: Deployable host — the single site that serves everything.
- **Responsibilities**: Configuration composition (including optional `appsettings.local.json`); module discovery and activation; middleware pipeline; static files and SPA fallbacks; `/api/v1` prefix convention; enum-as-string JSON; publish-time admin SPA build.
- **Dependencies**: Core, Modules.Identity, Modules.Availability, Modules.Master.
- **Type**: Application (Client / deployable).
### SlpModularCms.Api.Slave
- **Purpose**: Local second instance without the Master module, for exercising master↔slave behaviour.
- **Responsibilities**: Same host duties, minus central management. Uses its own database.
- **Dependencies**: Core, Modules.Identity, Modules.Availability.
- **Type**: Application (Client / deployable). No test project by design.
### SlpModularCms.Core
- **Purpose**: Shared domain layer
- **Responsibilities**: Domain entities, EF Core DbContext, authentication services, module interface
- **Dependencies**: EF Core, ASP.NET Identity, SQL Server provider
- **Type**: Shared Library
- **Purpose**: Shared foundation.
- **Responsibilities**: `ApplicationDbContext` and Identity entities; `AuthService`, `InvitationService`, `SetupService`; `HierarchicalRoleHandler` and the Owner/Admin/User policies; `IModule` + `ModuleOrchestrator`; `ApiPrefixConvention`; `GlobalExceptionHandler` and typed exceptions; `IAvailabilityService` contract; `SystemController` capability endpoint.
- **Dependencies**: EF Core + SQL Server provider, ASP.NET Core Identity, JwtBearer, Asp.Versioning, OpenAPI. References the ASP.NET Core shared framework.
- **Type**: Shared library.
### SlpModularCms.Modules.Identity
- **Purpose**: Identity and user management module
- **Responsibilities**: HTTP endpoints for auth, setup, and user invitation flows
- **Dependencies**: SlpModularCms.Core
- **Type**: Application Module
- **Purpose**: HTTP surface for accounts and access.
- **Responsibilities**: `AuthController`, `SetupController`, `InvitationController`, `UsersController`. Holds no persistence of its own.
- **Dependencies**: Core.
- **Type**: Application module.
### SlpModularCms.Modules.Availability
- **Purpose**: System availability tracking module
- **Responsibilities**: Exposes system status, allows owners to update it, caches with circuit breaker
- **Dependencies**: SlpModularCms.Core
- **Type**: Application Module
- **Purpose**: Decides whether this instance serves requests.
- **Responsibilities**: `AvailabilityMiddleware` (dual gate: master gate then local status, with bypass prefixes and admin-token bypass); `PersistentAvailabilityService`; `AvailabilityDbContext` holding `MasterRegistration`; `MasterController` for inbound master calls; `MasterStatusPollingBackgroundService` (pull + fail-open); Data Protectionencrypted master API key.
- **Dependencies**: Core.
- **Type**: Application module. Self-migrates at startup.
### SlpModularCms.Frontend (To Be Built)
- **Purpose**: Admin SPA for CMS management
- **Responsibilities**: Login, dashboard, user management, CMS content management, availability status display
- **Dependencies**: SlpModularCms.Api (REST)
- **Type**: Frontend Application
### SlpModularCms.Modules.Master
- **Purpose**: Central control point over other instances.
- **Responsibilities**: `MasterDbContext` with `CmsInstance`; `CmsInstanceController` (Owner-only); `SlaveStatusController` (anonymous, API-key authenticated pull endpoint); `SlaveApiClient` with retry + timeout resilience; `ApiKeyProtector` (Data Protection); `IntegrityCheckBackgroundService` for periodic reconciliation.
- **Dependencies**: Core, `Microsoft.Extensions.Http.Resilience`.
- **Type**: Application module. Self-migrates at startup.
### frontend (admin SPA)
- **Purpose**: Admin UI, served at `/admin` in production.
- **Responsibilities**: Auth with in-memory access token and silent refresh; pages for dashboard, users, invitations, profile, settings, CMS instances; capability and role guards; i18n (NL/EN); MSW-mocked tests.
- **Dependencies**: The API at `VITE_API_BASE_URL`.
- **Type**: Frontend application. Built into the API's `wwwroot/admin` on publish.
## Data Flow
### Login and silent refresh
```mermaid
sequenceDiagram
participant Browser
participant AuthController
participant AuthService
participant DB
Note over Browser,DB: Login Flow
Browser->>AuthController: POST /auth/login
AuthController->>AuthService: AuthenticateAsync()
AuthService->>DB: Validate credentials
DB-->>AuthService: User found
AuthService-->>AuthController: access + refresh tokens
AuthController-->>Browser: 200 OK with tokens
Note over Browser,DB: Invite Flow
Browser->>AuthController: POST /users/invite
AuthController->>AuthService: CreateInvitationAsync()
AuthService->>DB: Store Invitation entity
DB-->>AuthService: Stored
AuthService-->>AuthController: invite token
AuthController-->>Browser: 200 OK with invite link
Note over Browser,DB: New User Setup
Browser->>AuthController: POST /users/complete-setup
AuthController->>AuthService: CompleteInvitationAsync()
AuthService->>DB: Set password, activate account
DB-->>AuthService: Updated
AuthService-->>AuthController: success
AuthController-->>Browser: 200 OK
box rgba(246,224,94,0.4) Client
participant B as Browser (admin SPA)
end
box rgba(99,179,237,0.4) Host
participant A as AuthController
participant S as AuthService
end
box rgba(214,188,250,0.4) Data
participant D as SQL Server
end
B->>A: POST /api/v1/auth/login
A->>S: authenticate credentials
S->>D: verify user and persist refresh token
D-->>S: ok
S-->>A: access token plus refresh token
A-->>B: 200 with access token, refresh cookie set
B->>A: POST /api/v1/auth/refresh on startup
A->>S: rotate refresh token
S->>D: revoke old and store new
D-->>S: ok
A-->>B: 200 with new access token and cookie
```
Text alternative: The SPA logs in, the host verifies credentials and stores a refresh token, returning an access token plus an httpOnly cookie; on startup the SPA silently refreshes, rotating the stored token.
### Master registers a slave and pushes status
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Operator
participant O as Owner
end
box rgba(99,179,237,0.4) Master instance
participant M as CmsInstanceController
participant K as ApiKeyProtector
participant C as SlaveApiClient
end
box rgba(154,230,180,0.4) Slave instance
participant SL as MasterController
end
O->>M: POST /api/v1/CmsInstances with slave URL
M->>K: generate and encrypt API key
K-->>M: protected key stored
M->>C: push registration
C->>SL: POST /api/v1/master/register with X-Master-Api-Key
SL-->>C: 200 registered
O->>M: PUT /api/v1/CmsInstances/{id}/status
M->>C: push new status
C->>SL: POST /api/v1/master/status
SL-->>C: 200 applied
```
Text alternative: The Owner adds a slave by URL; the Master generates and encrypts an API key, pushes the registration to the slave, and later pushes each status change synchronously.
### Availability gate evaluation
```mermaid
graph TD
req["Incoming request"]
bypass{"Bypass prefix?<br/>Availability/status, Auth/,<br/>Setup/status, master/, SlaveStatus"}
adminbp{"Owner or Administrator<br/>bearer token?"}
mgate{"Master gate<br/>available?"}
local{"Local status<br/>Available?"}
pass["Continue pipeline"]
block["503 ProblemDetails"]
req --> bypass
bypass -->|yes| pass
bypass -->|no| adminbp
adminbp -->|yes| pass
adminbp -->|no| mgate
mgate -->|no| block
mgate -->|yes| local
local -->|yes| pass
local -->|no| block
classDef start fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class req start;
class bypass,adminbp,mgate,local decision;
class pass good;
class block bad;
```
Text alternative: Requests to bypass prefixes or carrying an Owner/Administrator token always pass; otherwise the master gate is checked first and then the local availability status, and failing either returns a 503 ProblemDetails.
## Integration Points
- **External APIs**: None currently
- **Databases**: SQL Server (via EF Core)
- **Third-party Services**: None currently
- **External APIs**: None inbound from third parties. Outbound: the Master module calls each registered slave's `/api/v1/master/*` endpoints; each slave calls its Master's `/api/v1/SlaveStatus`. Both are instances of this same product.
- **Databases**: One SQL Server database per instance, shared by three `DbContext` types via `ConnectionStrings:DefaultConnection`.
- **Third-party Services**: **None wired up.** There is currently no Sentry, Umami, structured-logging sink, or uptime/health endpoint anywhere in the codebase — logging is the default ASP.NET Core console provider only.
## Infrastructure Components
- **Deployment Model**: Single API process + React SPA (separate deploy or static files)
- **Authentication**: JWT Bearer tokens (HS256 or RS256 based on JwtSettings config)
- **Database Migrations**: EF Core Code-First migrations in SlpModularCms.Core/Migrations/
- **Deployment Model**: One published .NET application per instance, containing the API, the admin SPA under `wwwroot/admin/`, and the customer's public website under `wwwroot/`. Designed explicitly for shared hosting where **no server configuration is possible** — hence no reverse-proxy, nginx or container assumptions in the code. There are no CDK, Terraform, CloudFormation or Docker artifacts in the repository, and **no CI/CD pipeline exists yet** (no `.gitea/` directory).
- **Configuration**: Three-file appsettings pattern (`appsettings.json` baseline with placeholder values, `appsettings.Development.json`, gitignored `appsettings.local.json`). Production secrets are expected as environment variables using the `Section__Key` convention: `ConnectionStrings__DefaultConnection`, `JwtSettings__Secret`, `JwtSettings__Issuer`, `JwtSettings__Audience`, `MasterModule__MasterUrl`.
- **Networking**: `AllowedHosts` is `*`; CORS origins come from `Cors:AllowedOrigins` (empty in the production baseline — acceptable once the admin SPA is same-origin under `/admin`). `UseHttpsRedirection()` runs early in the pipeline and **no forwarded-headers middleware is configured**, which matters when the app sits behind a hosting provider's TLS-terminating proxy.
- **Database migrations**: `AvailabilityDbContext` and `MasterDbContext` call `Database.Migrate()` in their module's `UseModule`. `ApplicationDbContext` (Core/Identity) is **never** migrated automatically and requires an explicit `dotnet ef database update` or a generated SQL script per environment.
- **Key management**: Both `ApiKeyProtector` (Master) and `MasterApiKeyProtector` (Availability) use `services.AddDataProtection()` with the default file-system key ring. No persistent key store is configured, so a redeploy or recycle that discards the key folder makes stored slave API keys unreadable.
## Deployment-Relevant Observations
These are current facts about the code, recorded because they shape any deployment/CI design:
1. **No CI/CD exists yet** — this repository has no `.gitea/workflows/`.
2. **`dotnet publish` on `SlpModularCms.Api` requires Node and pnpm** on the build machine: the `BuildAndCopyAdminFrontend` target runs `pnpm install --frozen-lockfile` and `pnpm build` before publish.
3. **The admin SPA needs an absolute API base URL at build time.** `frontend/src/lib/config.ts` reads `VITE_API_BASE_URL` and validates it as a URL, so the bundle is environment-specific — a test build and a production build cannot be the same artifact unless this is changed to a same-origin/relative default.
4. **No health endpoint exists** — there is no `MapHealthChecks`, `/health` or readiness/liveness route anywhere. One has to be added before uptime monitoring can be wired up. Note that `Availability` and `System/capabilities` are **CMS domain functionality, not health checks**: availability is the product's own on/off state (local switch plus master gate) and capabilities reports which modules are loaded — both also serve the master↔slave protocol. Neither reflects application health, so neither may be repurposed for monitoring; a healthy instance can report `NotAvailable` by design, and a sick one can report `Available`.
5. **The public website at `/` is not behind the availability gate.** Static files are served before `orchestrator.UseModules(app)` installs `AvailabilityMiddleware`, so an existing `wwwroot/index.html` short-circuits the pipeline. Turning an instance "off" therefore blocks the API and admin SPA routes but still serves the public site's static files — relevant both to what "disabled" means commercially and to what an uptime check actually proves.
6. **`ModuleOrchestrator` discovers modules by globbing `SlpModularCms.Modules.*.dll` in the app base directory.** Which modules an instance has is therefore a property of what is deployed, not of configuration — a deployment pipeline can shape capability by which DLLs it ships.
7. **Data Protection has no persistent key ring**, so redeploys risk invalidating stored slave API keys (already flagged in the README).
@@ -1,70 +1,106 @@
# Business Overview
# Business Overview
## Business Context Diagram
```mermaid
graph TD
subgraph Platform["SlpModularCms Platform"]
Identity["Identity Module\n(Auth + Users)"]
CMS["CMS Module\n(Content Mgmt)"]
Availability["Availability Module\n(System Status)"]
Core["Core / Shell\n(Domain entities, DbContext, Module I/F)"]
end
owner["Owner<br/>(system owner)"]
admin["Administrator"]
enduser["User"]
visitor["Public website visitor"]
cms["SlpModularCms instance<br/>(single host process)"]
slave["Other CMS instances<br/>(slaves)"]
db[("SQL Server<br/>database")]
Identity --> Core
CMS --> Core
Availability --> Core
owner --> cms
admin --> cms
enduser --> cms
visitor --> cms
cms --> db
cms -->|"pushes availability status"| slave
slave -->|"polls own status"| cms
Platform --> AdminFrontend["Admin Frontend\n(React SPA)"]
Platform --> ExternalClients["External Clients\n(API consumers)"]
style Identity fill:#4CAF50,stroke:#2E7D32,color:#fff
style CMS fill:#4CAF50,stroke:#2E7D32,color:#fff
style Availability fill:#4CAF50,stroke:#2E7D32,color:#fff
style Core fill:#FFC107,stroke:#F57F17,color:#000
style AdminFrontend fill:#2196F3,stroke:#0D47A1,color:#fff
style ExternalClients fill:#9E9E9E,stroke:#424242,color:#fff
classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef system fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef external fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
class owner,admin,enduser,visitor actor;
class cms system;
class slave external;
class db store;
```
Text alternative: Owners, administrators, users and public visitors all interact with a single SlpModularCms host process, which persists to SQL Server and — when acting as a Master — centrally manages the availability of other (slave) CMS instances that in turn poll it for their own status.
## Business Description
- **Business Description**: SlpModularCms is a modular Content Management System (CMS) platform. It provides a REST API backend for managing CMS content, users, and system availability. The platform uses role-based access control (Owner, Admin, User) and supports a modular plugin architecture so that features can be added as independent modules.
- **Business Description**: SlpModularCms is a modular-monolith content management system built on .NET 10. A single deployed instance serves three surfaces from one host process: the customer's public website (`/`), the CMS administration UI (`/admin`), and the REST API (`/api/v1`). Functionality is delivered by pluggable modules discovered at startup, so one codebase can be deployed in different capability configurations. One instance can additionally take the role of **Master**, from which the system owner centrally enables or disables other ("slave") CMS instances — the commercial lever that lets the operator suspend a customer site (for example on non-payment) without needing access to that site's own hosting.
- **Business Transactions**:
- **User Authentication**: Login with email/password, receive JWT access + refresh token pair; refresh tokens for continued sessions; revoke tokens on logout.
- **System Initialization**: First-time setup — create initial Owner account before normal operations can begin.
- **User Invitation**: Admins and Owners invite new users by email; new users complete their account setup via an invitation link.
- **System Availability Management**: Owners can update the system availability status (Available / Maintenance / Unavailable); anyone can query current status.
- **CMS Content Management**: (Planned — module structure is in place but CMS-specific content modules are not yet implemented.)
| Transaction | Description |
|---|---|
| Initial system bootstrap | On a fresh installation the first Owner account is created via a one-time setup flow; afterwards the setup endpoint reports the system as initialized. |
| Authenticate a user | A user logs in and receives a short-lived access token plus a rotating refresh token held in an httpOnly cookie; the session refreshes silently and can be revoked. |
| Change own password | An authenticated user replaces their own password. |
| Invite and onboard a user | An administrator invites a person; the invitee validates the invitation token and completes registration to become an active user. |
| Manage users | An administrator lists users, changes a user's role, activates or deactivates a user, or deletes a user. Role changes obey a hierarchy (Owner > Administrator > User). |
| Maintain own profile | Any authenticated user updates their own profile details. |
| Control local availability | An Owner switches the instance between Available, NotAvailable and Maintenance, with an optional reason shown to blocked callers. |
| Register a slave instance | On the Master an Owner adds another CMS instance by URL; the Master generates an API key, stores it encrypted and pushes the registration to that instance. |
| Centrally set a slave's status | The Owner sets a registered instance to Available, NotAvailable or Inactive on the Master; the change is pushed to the slave synchronously. |
| Reconcile slave status | A recurring integrity check on the Master re-pushes the authoritative status to every active slave, and each slave independently polls the Master for its own status — so a restarted slave or a locally tampered status self-heals. |
| Discover instance capabilities | A client asks which optional modules are loaded on this instance, so it can hide features the deployment does not have rather than showing an error. |
| Serve the public website | An anonymous visitor loads the customer's public website from the same host process that runs the API. |
- **Business Dictionary**:
- **Owner**: Highest-privilege role; can manage users, modules, and system availability.
- **Admin**: Can manage users and CMS content within their scope.
- **User**: Standard access; can use CMS features but cannot manage system settings.
- **Module**: An independently deployable feature unit that integrates into the CMS shell.
- **Invitation**: A time-limited token sent to a new user allowing them to create their account.
- **Availability Status**: Available | Maintenance | Unavailable — represents the operational state of the system.
| Term | Meaning |
|---|---|
| **Module** | A self-contained functional unit implementing `IModule`, discovered from disk at startup. Determines what a deployed instance can do. |
| **Master** | An instance running the Master module, from which the availability of other instances is centrally managed. |
| **Slave** | An instance whose availability is (partly) controlled by a Master. Technically any instance running the Availability module that holds a Master registration. |
| **Availability status** | Whether an instance serves requests: `Available`, `NotAvailable`, or `Maintenance`. |
| **CMS instance status** | The Master's view of a registered instance: `Available`, `NotAvailable`, or `Inactive` (no longer managed by the Master; the gate is released). |
| **Master gate** | The check that blocks requests when the Master has marked this instance unavailable — separate from, and in addition to, the instance's own local availability switch. |
| **Fail-open** | Safety rule: if a slave cannot reach its Master for longer than a configured window, it reverts to Available, so an unreachable Master can never permanently block a site. |
| **Admin bypass** | An Owner or Administrator bearer token passes the availability gate, so administrators can always reach the system to switch it back on. |
| **Capability** | A module present on this instance, exposed so clients can distinguish "feature absent in this deployment" from "error". |
| **Owner / Administrator / User** | The hierarchical roles; a higher role satisfies every requirement of a lower one. |
| **Invitation** | A time-limited token allowing a named person to create an account. |
| **Public website** | The customer-facing site served at `/`. Built and deployed separately; **not part of this repository**. |
| **Admin SPA** | The CMS administration single-page application served at `/admin`, built from `frontend/`. |
## Component Level Business Descriptions
### SlpModularCms.Api
- **Purpose**: ASP.NET Core Web API host — the entry point for all HTTP requests.
- **Responsibilities**: Bootstraps the application, registers modules, configures middleware (auth, CORS, Swagger), exposes REST endpoints.
### SlpModularCms.Api (host / Client)
- **Purpose**: The deployable application. Boots the module system and serves all three surfaces — public website, admin SPA and API — from one process.
- **Responsibilities**: Compose configuration; discover and activate modules; serve static files and per-path SPA fallbacks; expose the API under a single `/api/v1` prefix; build and embed the admin SPA at publish time.
### SlpModularCms.Api.Slave (host / Client)
- **Purpose**: A second host representing an instance **without** the Master module, so master↔slave behaviour can be exercised locally.
- **Responsibilities**: Same as the Api host minus central management; deliberately references only Core, Identity and Availability.
### SlpModularCms.Core
- **Purpose**: Shared domain core — entities, DbContext, interfaces, services, and migrations.
- **Responsibilities**: Defines domain entities (ApplicationUser, ApplicationRole, Invitation, RefreshToken, GlobalAvailabilityState), persistence (EF Core + SQL Server), and shared service contracts.
- **Purpose**: The shared foundation every module builds on.
- **Responsibilities**: Identity, authentication and hierarchical authorization; the module contract and orchestrator; the availability contract; uniform RFC 9457 error responses; the `/api/v1` routing convention; capability reporting.
### SlpModularCms.Modules.Identity
- **Purpose**: Authentication and user management module.
- **Responsibilities**: Implements AuthController (login/refresh/revoke), SetupController (initial owner creation), UsersController (invite, complete-setup, validate-invitation).
- **Purpose**: Exposes account and access management to clients.
- **Responsibilities**: Login/refresh/revoke and password change; first-Owner setup; invitations; user administration.
### SlpModularCms.Modules.Availability
- **Purpose**: System availability / health status module.
- **Responsibilities**: Implements AvailabilityController (get status, update status), caches status in-memory with circuit breaker, persists status changes to the database.
- **Purpose**: Decides whether this instance serves requests, honouring both the local switch and the Master's verdict.
- **Responsibilities**: Persist local availability; hold the Master registration; enforce the gate as middleware with documented bypasses; poll the Master for its own status; fail open when the Master is unreachable.
### SlpModularCms.Core.Tests
- **Purpose**: Unit tests for the Core layer.
- **Responsibilities**: Tests for exception classes, invitation service logic, identity services.
### SlpModularCms.Modules.Master
- **Purpose**: Turns an instance into the central control point for other instances.
- **Responsibilities**: Register instances and issue encrypted API keys; push status changes to slaves; answer a slave's status poll; reconcile periodically so drift and restarts self-heal.
### SlpModularCms.Modules.Availability.Tests
- **Purpose**: Unit/integration tests for the Availability module.
- **Responsibilities**: Tests for availability service logic and controller behavior.
### frontend (admin SPA)
- **Purpose**: The web UI through which Owners, Administrators and Users operate the CMS.
- **Responsibilities**: Login and silent session refresh; dashboard; user and invitation management; profile; availability settings (locked when the Master controls it); Master instance management; capability-driven feature gating.
### Test projects
- **Purpose**: Protect the business rules above against regression.
- **Responsibilities**: `SlpModularCms.Core.Tests`, `SlpModularCms.Modules.Identity.Tests`, `SlpModularCms.Modules.Availability.Tests` and `SlpModularCms.Modules.Master.Tests` mirror the production projects; the admin SPA has its own Vitest suite. `SlpModularCms.Api.Slave` has no test project by design.
@@ -1,34 +1,115 @@
# Code Quality Assessment
# Code Quality Assessment
All figures below were **measured** during this analysis (2026-07-27) rather than inferred.
## Build
`dotnet build SlpModularCms.sln -c Release`**succeeds**: 0 errors, 50 warnings, ~27s.
Warning categories:
- **NU1903 — known high-severity vulnerabilities in transitive packages** (the majority of the 50). Confirmed by `dotnet list package --vulnerable --include-transitive`:
- `Microsoft.OpenApi` **2.0.0** — GHSA-v5pm-xwqc-g5wc (High)
- `System.Security.Cryptography.Xml` **10.0.9** — GHSA-cvvh-rhrc-wg4q and four further advisories (High)
Both arrive transitively (OpenAPI tooling; Data Protection's XML key handling). A CI gate on `dotnet list package --vulnerable` would fail today until these are pinned to patched versions.
- **NU1510 — redundant `PackageReference`s** that will not be pruned: `Microsoft.Extensions.Logging.Abstractions` (Core, Modules.Identity.Tests), `Microsoft.Extensions.Hosting.Abstractions` (Core.Tests). Cosmetic.
No C# compiler warnings — nullable reference types are respected throughout.
## Test Coverage
- **Overall**: Fair — unit tests exist for Core and Availability modules
- **Unit Tests**: Present for Core.Tests and Modules.Availability.Tests
- **Integration Tests**: Not observed in current structure
- **Frontend Tests**: None (example app has no test files)
### Backend — all suites pass
| Suite | Tests | Result | Duration |
|---|---|---|---|
| `SlpModularCms.Core.Tests` | 54 | ✅ all passed | 0.9s |
| `SlpModularCms.Modules.Identity.Tests` | 37 | ✅ all passed | 1.0s |
| `SlpModularCms.Modules.Availability.Tests` | 78 | ✅ all passed | 0.5s |
| `SlpModularCms.Modules.Master.Tests` | 50 | ✅ all passed | 0.6s |
| **Total** | **219** | **0 failed, 0 skipped** | ~3s |
### Frontend — all suites pass
`pnpm test` (Vitest): **34 test files, 213 tests, all passed**, ~39s. Every page, API hook and interactive component has a colocated test; MSW supplies request mocking per domain.
### Coverage posture
- **Unit tests**: Good and genuinely broad — controllers, services, repositories and background services are all covered on the backend; pages, hooks and dialogs on the frontend.
- **Integration tests**: **None.** There is no `WebApplicationFactory`-based suite, so the composed pipeline is never exercised end to end. The things that only exist in composition are therefore untested: middleware ordering, the availability gate's real interaction with static files, the `/api/v1` prefix convention, the two SPA fallbacks, CORS, rate limiting, and JWT validation against real configuration.
- **Contract tests**: **None** for the master↔slave protocol. Both sides are unit-tested in isolation with mocks, so a change to one side's contract would not be caught.
- **End-to-end tests**: None.
- **Coverage numbers**: `coverlet.runsettings` is configured (excluding migrations, `obj/`, generated OpenAPI interceptors and `[ExcludeFromCodeCoverage]` members), and the frontend has a `test:coverage` script with v8, but **no threshold is enforced anywhere** — nothing fails a build for dropping coverage.
## Code Quality Indicators
- **Linting**: Not explicitly configured (no .editorconfig or eslint config seen in backend; frontend likely uses Vite defaults)
- **Code Style**: Consistent — clean C# with XML doc comments on public interfaces and entities
- **Documentation**: Good for core interfaces and entities (XML doc comments); controllers have minimal comments
- **Naming**: Follows .NET conventions (PascalCase classes/methods, camelCase parameters)
- **Backend linting**: Nothing beyond compiler nullable warnings — no `.editorconfig`, no analyzer package, no format check. `dotnet format --verify-no-changes` is not wired up anywhere.
- **Frontend linting**: ESLint 10 with `typescript-eslint`, `react-hooks` and `react-refresh` plugins, plus Prettier with `format:check`. **`pnpm run lint` currently FAILS** — see Technical Debt below. This is a blocking fact for any CI pipeline that runs lint as a gate.
- **Type checking**: `tsc -b` runs as part of `pnpm build`, so type errors do fail the frontend build.
- **Code style**: Consistent within each side. Backend uses XML doc comments on interfaces, entities and non-obvious services; several comments explain *why* rather than *what* (the `nonfile` constraint, the reason `build-production` is a separate job in the reference project, the master-gate bypass rationale). Frontend is Prettier-formatted with 4-space indent.
- **Comment language**: **Mixed Dutch and English** in the C# codebase — `ModuleOrchestrator` logs and doc comments are Dutch, most newer code is English, and some user-facing strings are Dutch (`AvailabilityMiddleware`'s 503 detail, `SetupController`'s success message). Not a defect, but it means user-visible API messages are Dutch-only with no localisation path, while the frontend is fully i18n'd (NL/EN).
- **Documentation**: Strong. `README.md` is thorough and current (including the single-host model and production setup); `CLAUDE.md`/`AGENTS.md`/`.junie/guidelines.md`/`.github/copilot-instructions.md` document the solution layout; `aidlc-docs/` holds the full AI-DLC history per feature.
- **Naming**: Follows .NET and React conventions consistently.
- **Reproducibility**: `frontend/pnpm-lock.yaml` exists and publish uses `--frozen-lockfile`. **No `packages.lock.json` for any .NET project**, so NuGet restore is not locked.
## Technical Debt
- Auth context in example React app uses `localStorage` for user state (security concern — no httpOnly cookies)
- Example app auth-context simulates login locally without real API calls (will need to be replaced with actual API integration)
- No CORS configuration confirmed in backend (needs verification for SPA integration)
- `AvailabilityController.UpdateStatus` uses a direct service cast (`as PersistentAvailabilityService`) which couples controller to implementation
- No OpenAPI/Swagger spec currently integrated (would help frontend integration)
### Blocking for CI as it stands
1. **`pnpm run lint` fails: 5 errors, 1 warning.** Any workflow that gates on lint will go red on the current `master`:
- `src/components/cms/AddCmsInstanceDialog.tsx:55``setState` called synchronously inside an effect (`react-hooks/set-state-in-effect`)
- `src/components/users/InviteUserDialog.tsx:50` — same rule
- `src/components/users/InviteUserDialog.tsx:54` — variable accessed before declaration
- `src/pages/SettingsPage.tsx:40` — same `setState`-in-effect rule
- `src/components/cms/SetStatusDialog.tsx:32``react-refresh/only-export-components`: a non-component export shares the file
- `src/components/cms/SetStatusDialog.tsx:72` — warning: "Compilation Skipped: Use of incompatible library"
Note the tests all pass regardless — these are lint-rule violations, not observed runtime failures.
2. **Two high-severity transitive vulnerabilities** (`Microsoft.OpenApi` 2.0.0, `System.Security.Cryptography.Xml` 10.0.9). A vulnerability gate cannot be switched on until these are addressed.
### Deployment and operations gaps
3. **No CI/CD whatsoever** — no `.gitea/workflows/`, no build/test/deploy automation. Every deployment is manual today.
4. **No health-check endpoint.** There is nothing to point uptime monitoring at, and no existing endpoint can stand in: `Availability` and `System/capabilities` are CMS domain functionality (product on/off state and loaded-module reporting, both also serving the master↔slave protocol), not health signals. A dedicated health check — outside `/api/v1` domain routing and outside the availability gate, reporting infrastructure liveness such as process up, database reachable and migrations applied — has to be built. It is **in scope for the `gitea-deployment-workflow` feature** because the framework supplies it almost for free: `AddHealthChecks()` + `MapHealthChecks("/health")` require no package, and a database probe costs only `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` 10.0.9 plus `.AddDbContextCheck<ApplicationDbContext>()`. `/health` must be added to `AvailabilityMiddleware._bypassPrefixes`, or the gate will return 503 for it on a disabled instance. See `api-documentation.md` § Observability endpoints.
5. **No observability.** No Sentry, no structured logging, no analytics, no metrics or tracing. Logging is the console provider at `Warning` level in the production baseline — which means production would emit almost nothing useful.
6. **Data Protection has no persistent key ring.** Both `ApiKeyProtector` and `MasterApiKeyProtector` use the default file-system store. A redeploy or app-pool recycle that discards the key folder makes every stored slave API key permanently unreadable, silently breaking master↔slave communication until instances are re-added. Already flagged in `README.md`; still unaddressed in code.
7. **No forwarded-headers middleware**, while `UseHttpsRedirection()` runs early. Behind a hosting provider's TLS-terminating proxy the app sees plain HTTP, which can cause redirect loops or wrong scheme in generated URLs. There is no `UseForwardedHeaders` and no `ASPNETCORE_FORWARDEDHEADERS_ENABLED` guidance.
8. **The admin SPA bundle is environment-specific.** `frontend/src/lib/config.ts` requires `VITE_API_BASE_URL` as an absolute URL, so a test build and a production build cannot be the same artifact — even though in the single-host model the API is same-origin with the SPA and a relative base would work. This forces "build twice" in CI (exactly the pattern the reference project had to adopt for `VITE_APP_ENV`), or a small code change to default to same-origin.
9. **`dotnet publish` requires Node and pnpm** because of the `BuildAndCopyAdminFrontend` target. Convenient locally; a hard constraint on any build agent, and it couples backend publish time to frontend install/build time.
10. **`ApplicationDbContext` migrations are never applied automatically** while the two module contexts are. This asymmetry means a fresh deployment silently starts with no Identity tables until someone runs `dotnet ef database update`, and there is no migration step in any pipeline (because there is no pipeline).
11. **No `Test` environment configuration.** Only `appsettings.json` and `appsettings.Development.json` exist; there is no `appsettings.Test.json` and no defined `ASPNETCORE_ENVIRONMENT` value for the test environment, even though three environments are in scope.
12. **The production `appsettings.json` ships placeholder secrets** (`<secure-long-random-secret-key-from-env>`, `<production-db-host>`). Safe — they are not real credentials, and env vars are expected to override them — but startup fails confusingly rather than clearly if an env var is missed, since `JwtSettings:Secret` is present-but-nonsense rather than absent.
### Design-level debt
13. **The availability gate does not cover the public website.** Static files are served before `orchestrator.UseModules(app)` installs `AvailabilityMiddleware`, so disabling an instance blocks the API and admin routes but still serves `wwwroot/index.html` and its assets. Whether that is intended is a product decision, but it is currently implicit rather than documented, and it changes what "we disabled that customer" actually means.
14. **`AvailabilityController.UpdateStatus` casts the injected `IAvailabilityService` to `PersistentAvailabilityService`** and returns `400` if the cast fails — the controller depends on a concrete implementation. Carried over from the previous assessment; still present.
15. **Module load failures are swallowed.** `ModuleOrchestrator` logs and continues when an assembly cannot be loaded or a module cannot be instantiated. A deployment that ships a broken or missing module DLL starts up "successfully" with reduced capability rather than failing fast — hard to detect without monitoring, and directly relevant to trusting a deployment.
16. **`AvailabilityMiddleware.IsAdminBypass` reads the JWT without validating its signature** (`JwtSecurityTokenHandler.ReadJwtToken`), so anyone can craft an unsigned token carrying an `Owner` role claim and bypass the availability gate. Requests still fail authentication at protected endpoints afterwards, so this is not a privilege escalation — but it does mean the gate is bypassable by an unauthenticated caller, including on a master-disabled instance.
17. **Inconsistent persistence style**: `Modules.Master` and `Modules.Availability` use the repository pattern; `Core`'s identity services use `ApplicationDbContext` and Identity managers directly. Recognised divide between older and newer code.
18. **`Microsoft.Extensions.Http.Resilience` 9.6.0 on `net10.0` targets** — the only dependency out of step with the otherwise uniform 10.0.x line.
19. **API error and status messages are Dutch-only**, with no localisation mechanism on the backend, while the frontend is fully bilingual.
## Patterns and Anti-patterns
### Good Patterns
- Module pattern provides clear separation of concerns between features
- JWT refresh token rotation is properly implemented
- Authorization policies are well-defined (OwnerOnly, AdminOnly)
- EF Core used consistently for persistence
- Service interfaces (IAuthService, IInvitationService, ISetupService) for testability
- **Module/plugin architecture** with reflection-based discovery — capability is a property of what is deployed, which makes the master/slave distinction a packaging concern rather than a configuration flag.
- **Push *and* pull status synchronisation with fail-open** — the master↔slave design assumes messages get lost and instances restart, and it self-heals in both directions without a broker. The fail-open rule is the right default for a commercial kill-switch.
- **`UpdateStatusResult { success, slaveContactSuccess }`** — honestly reports partial success instead of collapsing two different outcomes into one boolean.
- **JWT with refresh-token rotation**, access token in memory only, refresh cookie httpOnly and path-scoped to `/api/v1/auth`.
- **Uniform RFC 9457 `ProblemDetails`** via a global handler, mirrored by a typed `ProblemDetailsError` in the frontend client.
- **Centralised route prefixing** (`ApiPrefixConvention`) rather than repeating `api/v1` in every controller.
- **The `nonfile` route constraint** on both SPA fallbacks — missing assets still 404 instead of being handed an HTML page, which is a genuinely easy mistake to make.
- **Options pattern** used consistently for all four configuration sections.
- **Resilience pipeline** on outbound master→slave calls with jittered exponential backoff.
- **High, real test coverage** with fast suites (219 backend tests in ~3s) and MSW-based frontend tests that avoid brittle mocking.
- **Comments that explain rationale**, not mechanics — several of the trickiest decisions in the codebase are documented at the point of the decision.
- **Three-file appsettings pattern** with `appsettings.local.json` gitignored and no real secrets committed.
### Anti-patterns
- Direct implementation cast in `AvailabilityController` (should use extended interface instead)
- Example React app uses localStorage-based auth (acceptable for prototype, not production)
- Example React app `auth-context` hardcodes mock users (must be replaced with real API calls)
- Concrete-type cast in `AvailabilityController.UpdateStatus` (item 14).
- Unvalidated JWT parsing in the availability gate's admin bypass (item 16).
- Silent module-load failure (item 15).
- Build-time environment coupling in the frontend config, forcing per-environment bundles (item 8).
- Backend project reference used purely as a deployment mechanism for module DLLs — it works and is documented, but the compile-time dependency does not reflect an actual code dependency.
- Asymmetric migration strategy across the three `DbContext` types (item 10).
- Mixed-language comments and Dutch-only user-facing API strings (items 19 and the note above).
@@ -1,119 +1,253 @@
# Code Structure
# Code Structure
## Build System
- **Type**: .NET SDK (MSBuild / dotnet CLI)
- **Configuration**: `SlpModularCms.sln` — solution file referencing all projects
- **Target Framework**: `net10.0`
- **Type**: .NET SDK (MSBuild / `dotnet` CLI) for the backend; pnpm + Vite for the admin SPA.
- **Solution**: `SlpModularCms.sln` — 10 projects, organised into three top-level Solution Folders (see `CLAUDE.md` / `AGENTS.md`):
- **Application** — `SlpModularCms.Core` plus a nested **Modules** folder (`Modules.Master`, `Modules.Identity`, `Modules.Availability`)
- **Tests** — mirrors Application, with its own nested **Modules** folder
- **Clients** — the deployable hosts: `SlpModularCms.Api`, `SlpModularCms.Api.Slave`
- **Target framework**: `net10.0` for every project. SDK in use: 10.0.301.
- **Key build settings**: `Nullable` and `ImplicitUsings` enabled everywhere. `SlpModularCms.Core` uses `<FrameworkReference Include="Microsoft.AspNetCore.App" />` so a class library can depend on ASP.NET Core types.
- **Coverage**: `coverlet.runsettings` at the repository root excludes migrations, `obj/`, generated OpenAPI interceptors, and anything marked `[ExcludeFromCodeCoverage]`.
- **Frontend build**: `frontend/package.json``build` runs `tsc -b && vite build`; `vite.config.ts` sets `base: '/admin/'` for `command === 'build'` only, so the dev server still serves from `/`.
- **Publish coupling**: `SlpModularCms.Api.csproj` defines the `BuildAndCopyAdminFrontend` target with `BeforeTargets="Publish"`, which runs `pnpm install --frozen-lockfile` and `pnpm build` in `frontend/` and copies `frontend/dist/**` into `wwwroot/admin/`. **`dotnet publish` therefore requires Node and pnpm on the build machine.** `wwwroot/` is gitignored.
## Project Structure
```mermaid
graph TD
Root["SlpModularCms/"]
Src["src/"]
Api["SlpModularCms.Api\n(API host)"]
ApiExt["Extensions/\nServiceCollectionExtensions.cs"]
ApiInfra["Infrastructure/\nGlobal exception handler"]
ApiProg["Program.cs\nApp startup + module loading"]
root["SlpModularCms (repo root)"]
sln["SlpModularCms.sln"]
src["src/"]
fe["frontend/ (admin SPA)"]
docs["aidlc-docs/"]
Core["SlpModularCms.Core\n(Shared core)"]
CoreAvail["Availability/\nAvailabilityOptions.cs\nAvailabilityStatus.cs"]
CoreData["Data/\nApplicationDbContext.cs"]
CoreIdentity["Identity/\nEntities, Models, Services\nAuthorization/"]
CoreMigrations["Migrations/\nEF Core migrations"]
CoreModules["Modules/\nIModule.cs, ModuleInfo.cs"]
api["SlpModularCms.Api<br/>Client / host"]
slave["SlpModularCms.Api.Slave<br/>Client / host"]
core["SlpModularCms.Core<br/>shared library"]
mid["Modules.Identity"]
mav["Modules.Availability"]
mma["Modules.Master"]
tests["4 test projects<br/>Core, Identity, Availability, Master"]
ModIdentity["SlpModularCms.Modules.Identity\n(Identity module)"]
ModIdentityCtrl["Controllers/\nAuthController\nSetupController\nUsersController"]
root --> sln
root --> src
root --> fe
root --> docs
src --> api
src --> slave
src --> core
src --> mid
src --> mav
src --> mma
src --> tests
ModAvail["SlpModularCms.Modules.Availability\n(Availability module)"]
ModAvailCtrl["Controllers/\nAvailabilityController"]
ModAvailSvc["Services/\nPersistentAvailabilityService"]
Tests1["SlpModularCms.Core.Tests"]
Tests2["SlpModularCms.Modules.Availability.Tests"]
Docs["aidlc-docs/\nAI-DLC workflow documentation"]
Root --> Src
Root --> Docs
Src --> Api
Src --> Core
Src --> ModIdentity
Src --> ModAvail
Src --> Tests1
Src --> Tests2
Api --> ApiExt
Api --> ApiInfra
Api --> ApiProg
Core --> CoreAvail
Core --> CoreData
Core --> CoreIdentity
Core --> CoreMigrations
Core --> CoreModules
ModIdentity --> ModIdentityCtrl
ModAvail --> ModAvailCtrl
ModAvail --> ModAvailSvc
style Api fill:#4CAF50,stroke:#2E7D32,color:#fff
style ApiExt fill:#4CAF50,stroke:#2E7D32,color:#fff
style ApiInfra fill:#4CAF50,stroke:#2E7D32,color:#fff
style ApiProg fill:#4CAF50,stroke:#2E7D32,color:#fff
style Core fill:#FFC107,stroke:#F57F17,color:#000
style CoreAvail fill:#FFC107,stroke:#F57F17,color:#000
style CoreData fill:#FFC107,stroke:#F57F17,color:#000
style CoreIdentity fill:#FFC107,stroke:#F57F17,color:#000
style CoreMigrations fill:#FFC107,stroke:#F57F17,color:#000
style CoreModules fill:#FFC107,stroke:#F57F17,color:#000
style ModIdentity fill:#4CAF50,stroke:#2E7D32,color:#fff
style ModIdentityCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff
style ModAvail fill:#4CAF50,stroke:#2E7D32,color:#fff
style ModAvailCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff
style ModAvailSvc fill:#4CAF50,stroke:#2E7D32,color:#fff
style Tests1 fill:#9E9E9E,stroke:#424242,color:#fff
style Tests2 fill:#9E9E9E,stroke:#424242,color:#fff
style Docs fill:#CE93D8,stroke:#6A1B9A,color:#000
classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
classDef meta fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
class api,slave,fe client;
class core corelayer;
class mid,mav,mma module;
class tests test;
class root,sln,src,docs meta;
```
Text alternative: The repository root holds the solution file, a `src/` folder with two host projects, Core, three modules and four test projects, plus a separate `frontend/` admin SPA and the `aidlc-docs/` documentation tree.
## Key Classes/Modules
### Core Domain Entities
- `ApplicationUser` — extends `IdentityUser<Guid>` with `IsActive`, `CreatedAt`, `Naam`
- `ApplicationRole` — extends `IdentityRole<Guid>`
- `RefreshToken` — linked to user; has `Token`, `ExpiryDate`, `IsRevoked`, `IsActive`
- `Invitation` — linked to user (invitee); has `Token`, `ExpiryDate`, `IsUsed`, `Role`
- `GlobalAvailabilityState` — singleton-ish entity storing `Status`, `Message`, `LastUpdatedAt`, `UpdatedBy`
```mermaid
classDiagram
class IModule {
+string Name
+string Version
+RegisterServices(IServiceCollection)
+UseModule(IApplicationBuilder)
}
class ModuleOrchestrator {
+IReadOnlyList~string~ ModuleNames
+DiscoverModules()
+RegisterModuleServices(IServiceCollection)
+UseModules(IApplicationBuilder)
}
class IdentityModule
class AvailabilityModule
class MasterModule
class IAvailabilityService {
+IsAvailableAsync()
+UpdateStatusAsync()
}
class PersistentAvailabilityService
class IMasterAvailabilityService {
+GetMasterStatus()
}
class MasterAvailabilityService
### Core Services
- `IAuthService` / `AuthService``AuthenticateAsync`, `RefreshTokenAsync`, `RevokeTokenAsync`
- `IInvitationService` / `InvitationService``CreateInvitationAsync`, `CompleteInvitationAsync`, `ValidateInvitationAsync`
- `ISetupService` / `SetupService``IsSystemInitializedAsync`, `CreateInitialOwnerAsync`
- `IAvailabilityService` / `PersistentAvailabilityService``IsAvailableAsync`, `UpdateStatusAsync`
IModule <|.. IdentityModule
IModule <|.. AvailabilityModule
IModule <|.. MasterModule
ModuleOrchestrator --> IModule
IAvailabilityService <|.. PersistentAvailabilityService
IMasterAvailabilityService <|.. MasterAvailabilityService
```
### Module System
- `IModule` — interface: `RegisterServices(IServiceCollection)`, `UseModule(IApplicationBuilder)`
- Modules discovered at startup and invoked in sequence
Text alternative: `ModuleOrchestrator` works against the `IModule` contract implemented by the three modules; the Availability module supplies the concrete availability and master-gate services behind Core's interfaces.
### Existing Files Inventory
#### Clients (deployable hosts)
- `src/SlpModularCms.Api/Program.cs` — Host composition: local settings overlay, module discovery, core infrastructure/CORS/rate limiting, `/api/v1` convention, enum-as-string JSON, exception handler, HTTPS redirect, static files, CORS, module middleware, auth, controllers, and the two SPA fallbacks (`/admin/{*path:nonfile}``admin/index.html`, `{*path:nonfile}``index.html`).
- `src/SlpModularCms.Api/Program.Coverage.cs` — Coverage-support partial.
- `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — Package/project references plus the `BuildAndCopyAdminFrontend` publish target.
- `src/SlpModularCms.Api/appsettings.json` — Production baseline with placeholder secrets; `Logging` default `Warning`; `AllowedHosts: "*"`; empty `Cors:AllowedOrigins`; `JwtSettings`, `Availability`, `MasterModule`, `MasterPolling`, `RateLimiting` sections.
- `src/SlpModularCms.Api/appsettings.Development.json` — LocalDB connection, dev JWT secret, `CookieSameSite: None`, CORS for `localhost:5173`, relaxed rate limits, `MasterUrl: https://localhost:7221`.
- `src/SlpModularCms.Api/appsettings.local.json` — Gitignored developer overrides.
- `src/SlpModularCms.Api/Properties/launchSettings.json``http` (5284) and `https` (7221) profiles, both `Development`, launching `/scalar`.
- `src/SlpModularCms.Api.Slave/Program.cs`, `…/appsettings*.json`, `…/Properties/launchSettings.json` — Second host on 7222; no Master module reference; expects its own database.
#### Core
- `src/SlpModularCms.Core/Hosting/ModuleOrchestrator.cs` — Globs `SlpModularCms.Modules.*.dll` from `AppDomain.CurrentDomain.BaseDirectory`, loads assemblies, instantiates every non-abstract `IModule`, and exposes `ModuleNames`. Failures are logged, not thrown.
- `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs``AddCoreInfrastructure` (DbContext, Identity with password policy, JWT bearer with `ClockSkew.Zero`, the three hierarchical policies, exception handler + ProblemDetails, API versioning, OpenAPI), `AddCmsCors`, `AddCmsRateLimiting` (fixed-window `login`, sliding-window `refresh`).
- `src/SlpModularCms.Core/Hosting/ApiPrefixConvention.cs` — Applies the single `api/v1` prefix to every controller.
- `src/SlpModularCms.Core/Hosting/SystemController.cs``GET /api/v1/System/capabilities`, returns loaded module names.
- `src/SlpModularCms.Core/Data/ApplicationDbContext.cs` — Identity + `RefreshToken`, `Invitation`, `ModulePermission`, `GlobalAvailabilityState`.
- `src/SlpModularCms.Core/Identity/Entities/*.cs``ApplicationUser`, `ApplicationRole`, `RefreshToken`, `Invitation`, `ModulePermission`, `GlobalAvailabilityState`.
- `src/SlpModularCms.Core/Identity/Models/*.cs``IdentityRequests`, `JwtSettings`, `TokenResponse`.
- `src/SlpModularCms.Core/Identity/Services/{AuthService,IAuthService,InvitationService,IInvitationService,SetupService}.cs` — Login/refresh/revoke with token rotation, invitation lifecycle, first-Owner bootstrap.
- `src/SlpModularCms.Core/Identity/Authorization/{HierarchicalRoleHandler,HierarchicalRoleRequirement}.cs` — Owner > Administrator > User satisfaction.
- `src/SlpModularCms.Core/Availability/{AvailabilityOptions,AvailabilityStatus,AvailabilityStatusDetails,IAvailabilityService,MasterControlledAvailabilityException}.cs` — Availability contract; the exception is what turns a local override attempt into `409 Conflict` while master-controlled.
- `src/SlpModularCms.Core/Exceptions/{GlobalExceptionHandler,ValidationException,UnauthorizedException,InvitationOrUserAlreadyExistsException}.cs` — RFC 9457 mapping.
- `src/SlpModularCms.Core/Modules/{IModule,ModuleInfo}.cs` — Module contract.
- `src/SlpModularCms.Core/Migrations/` — 5 files; `ApplicationDbContext` migrations, applied **manually only**.
#### Modules.Identity
- `Controllers/AuthController.cs``login` (rate-limited `login`), `refresh` (rate-limited `refresh`), `revoke`, `change-password`.
- `Controllers/SetupController.cs``status`, `owner`.
- `Controllers/InvitationController.cs``validate`, `complete`; anonymous.
- `Controllers/UsersController.cs` — list, `me` update, `invite`, role/active updates, delete; `AdminOnly` by default.
- `IdentityModule.cs` — Module registration.
#### Modules.Availability
- `AvailabilityModule.cs` — Registers services, `AvailabilityDbContext`, Data Protection, polling `HttpClient` and hosted service; on `UseModule` runs `Database.Migrate()` and installs `AvailabilityMiddleware`.
- `Middleware/AvailabilityMiddleware.cs` — Bypass prefixes, admin-token bypass, master gate then local status, 503 `ProblemDetails` otherwise.
- `Services/PersistentAvailabilityService.cs` — Persisted local status with caching/circuit breaker; throws `MasterControlledAvailabilityException` on local override while master-controlled.
- `Services/{MasterAvailabilityService,MasterGateStatus,MasterStatusPollClient,MasterApiKeyProtector,…}.cs` — Master gate state, poll client, encrypted key handling, DI dependency bundle.
- `BackgroundServices/MasterStatusPollingBackgroundService.cs` — Periodic pull with fail-open.
- `Controllers/{AvailabilityController,MasterController}.cs` — Public status + Owner-only update; inbound master register/status/registered-url.
- `Data/AvailabilityDbContext.cs`, `Data/Entities/MasterRegistration.cs`, `Config/MasterPollingOptions.cs`, `Repositories/*`, `Models/MasterModels.cs`; `Migrations/` — 5 files, auto-applied.
#### Modules.Master
- `MasterModule.cs` — Data Protection, `MasterModuleOptions`, `MasterDbContext`, repositories/services, `SlaveApiClient` with a `slave-resilience` handler (2 retries, exponential backoff with jitter, configurable timeout), `IntegrityCheckBackgroundService`, `HttpContextAccessor`; migrates on `UseModule`.
- `Controllers/CmsInstanceController.cs` — Owner-only list/create/update-status.
- `Controllers/SlaveStatusController.cs` — Anonymous pull endpoint for slaves.
- `Services/{CmsInstanceService,SlaveApiClient,ApiKeyProtector,MasterServiceDependencies,…}.cs`
- `BackgroundServices/IntegrityCheckBackgroundService.cs` — Periodic reconciliation and status re-push.
- `Data/MasterDbContext.cs`, `Data/Entities/{CmsInstance,CmsInstanceStatus}.cs`, `Models/*`, `Options/MasterModuleOptions.cs`, `Repositories/*`; `Migrations/` — 3 files, auto-applied.
#### Tests
- `src/SlpModularCms.Core.Tests/` — 7 files (Exceptions, Hosting, Identity).
- `src/SlpModularCms.Modules.Identity.Tests/` — 4 files (Controllers).
- `src/SlpModularCms.Modules.Availability.Tests/` — 10 files (Controllers, Services, Repositories, BackgroundServices).
- `src/SlpModularCms.Modules.Master.Tests/` — 7 files (Controllers, Services, Repositories, BackgroundServices).
#### frontend (admin SPA)
- `frontend/vite.config.ts``base: '/admin/'` on build, `@` alias, dev port 5173, Vitest config with v8 coverage.
- `frontend/package.json` — Scripts `dev`, `dev:slave` (mode `slave`, port 5174), `dev:all` (concurrently), `build`, `lint`, `format`, `format:check`, `test`, `test:watch`, `test:coverage`, `preview`.
- `frontend/.env.example``VITE_API_BASE_URL`, `VITE_APP_TITLE`, plus notes for the slave setup. `.env.local` / `.env.slave.local` are local-only.
- `frontend/src/lib/config.ts` — Reads `VITE_API_BASE_URL` and `VITE_APP_TITLE`; Zod-validates `apiBaseUrl` as a URL, warning only in dev. **Makes the production bundle environment-specific.**
- `frontend/src/lib/api-client.ts``fetch` wrapper: credentials always sent, in-memory access token, 401 refresh-and-retry interceptor, `ProblemDetailsError` and `NetworkError`.
- `frontend/src/router.tsx` — TanStack Router with `basepath: import.meta.env.BASE_URL`, so routing follows the `/admin/` base automatically.
- `frontend/src/main.tsx` — Sets document title from config, React Query client, optional MSW via `VITE_ENABLE_MSW`, renders only after the initial silent refresh settles.
- `frontend/src/api/use*.ts` — Typed hooks per resource (availability, users, profile, invitation, setup, CMS instances, system capabilities), each with a colocated test.
- `frontend/src/pages/` — 10 pages, each with a test.
- `frontend/src/components/{auth,cms,layout,shared,ui,users}/` — Guards (`ModuleGuard`, `RoleGuard`), CMS instance dialogs/list, layout shell, shadcn-style primitives.
- `frontend/src/{contexts,hooks,i18n,mocks,test}/` — Auth provider, hooks, NL/EN translations, MSW handlers per domain, test setup.
## Design Patterns
### Module Pattern
- **Location**: `SlpModularCms.Core/Modules/`, `SlpModularCms.Api/Program.cs`
- **Purpose**: Allows features to be developed, tested, and deployed independently
- **Implementation**: Each module class implements `IModule` and is registered in the API host
### Repository Pattern via EF Core
- **Location**: `ApplicationDbContext` used directly in services
- **Purpose**: Centralized persistence with Entity Framework
### Module / plugin pattern
- **Location**: `Core/Modules/IModule.cs`, `Core/Hosting/ModuleOrchestrator.cs`, each `*Module.cs`.
- **Purpose**: Let one codebase deploy with different capability sets.
- **Implementation**: Reflection-based discovery of `SlpModularCms.Modules.*.dll` in the app base directory; each module registers services and middleware itself. Deployment content, not configuration, decides capability.
### JWT with Refresh Token Rotation
- **Location**: `AuthService.cs`, `AuthController.cs`
- **Purpose**: Stateless auth with token refresh capability
### Repository pattern
- **Location**: `Modules.Master/Repositories/`, `Modules.Availability/Repositories/`.
- **Purpose**: Keep EF Core access behind an interface so services stay unit-testable.
- **Implementation**: Interface plus EF-backed implementation per aggregate. Core's identity services use `ApplicationDbContext`/Identity managers directly rather than repositories — an intentional inconsistency between old and new code.
### Options pattern
- **Location**: `JwtSettings`, `AvailabilityOptions`, `MasterModuleOptions`, `MasterPollingOptions`.
- **Purpose**: Bind configuration sections to typed objects.
- **Implementation**: `services.Configure<T>` / `AddOptions<T>().BindConfiguration(...)`.
### Dependency-bundle (parameter object)
- **Location**: `MasterServiceDependencies`, `MasterAvailabilityServiceDependencies`.
- **Purpose**: Keep constructors manageable where a service needs many collaborators.
### Middleware gate
- **Location**: `AvailabilityMiddleware`.
- **Purpose**: Enforce availability centrally rather than per controller, with explicit bypasses.
### Background reconciliation (push + pull)
- **Location**: `IntegrityCheckBackgroundService` (master push), `MasterStatusPollingBackgroundService` (slave pull, fail-open).
- **Purpose**: Make distributed status self-healing without a message broker.
### JWT with refresh-token rotation
- **Location**: `AuthService`, `AuthController`, `frontend/src/lib/api-client.ts`.
- **Purpose**: Short-lived access tokens held in memory; rotating refresh token in an httpOnly cookie scoped to `/api/v1/auth`.
### Global exception handling to RFC 9457
- **Location**: `GlobalExceptionHandler` + typed exceptions.
- **Purpose**: One error contract for all clients.
### Resilience pipeline
- **Location**: `MasterModule` `slave-resilience` handler.
- **Purpose**: Tolerate slow or briefly unreachable slaves without failing the Owner's action outright.
## Critical Dependencies
### ASP.NET Core Identity
- **Version**: .NET 10 built-in
- **Usage**: User/Role management, password hashing
- **Purpose**: Provides authentication primitives
### Entity Framework Core
- **Version**: .NET 10 built-in
- **Usage**: Data persistence with SQL Server provider
- **Purpose**: ORM for all domain entities
### Microsoft.EntityFrameworkCore.SqlServer — 10.0.9
- **Usage**: All three `DbContext` types, one shared connection string.
- **Purpose**: Persistence. Module contexts self-migrate; the Core context does not.
### Microsoft.AspNetCore.Identity.EntityFrameworkCore — 10.0.9
- **Usage**: `ApplicationUser`/`ApplicationRole` stores, password hashing, policy.
- **Purpose**: Account primitives.
### Microsoft.AspNetCore.Authentication.JwtBearer — 10.0.9
- **Usage**: Token validation in `AddCoreInfrastructure` with `ClockSkew.Zero`.
- **Purpose**: Stateless authentication. Requires `JwtSettings:Secret` to be present or startup throws.
### ASP.NET Core Data Protection (shared framework)
- **Usage**: `ApiKeyProtector`, `MasterApiKeyProtector`.
- **Purpose**: Encrypt slave API keys at rest. **Default file-system key ring with no persistent store configured** — a redeploy that loses the key folder makes stored keys unreadable.
### Microsoft.Extensions.Http.Resilience — 9.6.0
- **Usage**: `SlaveApiClient`.
- **Purpose**: Retry and timeout for master→slave calls. Note: 9.x package on a `net10.0` target.
### Asp.Versioning.Mvc — 10.0.0
- **Usage**: `AddApiVersioning` with `ReportApiVersions`.
- **Purpose**: Version reporting alongside the static `/api/v1` prefix convention.
### Scalar.AspNetCore — 2.16.3
- **Usage**: `MapScalarApiReference()`, Development only.
- **Purpose**: API reference UI at `/scalar`. Not exposed in production.
### Vite 8 + React 19 + TanStack Router/Query (frontend)
- **Usage**: Admin SPA build and runtime.
- **Purpose**: `base: '/admin/'` and `basepath: import.meta.env.BASE_URL` are what make the `/admin` mount work.
### pnpm (build-time, backend publish)
- **Usage**: Invoked from `SlpModularCms.Api.csproj` during publish.
- **Purpose**: Builds the admin SPA. Makes Node + pnpm a hard prerequisite of `dotnet publish`.
@@ -1,23 +1,51 @@
# Component Inventory
# Component Inventory
Solution folders in `SlpModularCms.sln` follow the layout mandated by `CLAUDE.md` / `AGENTS.md`: **Application** (with nested **Modules**), **Tests** (mirroring Application, also with nested **Modules**), and **Clients** (the deployable projects).
## Clients (deployable hosts)
- `src/SlpModularCms.Api` — The production host. Serves the public website (`/`), the admin SPA (`/admin`) and the API (`/api/v1`) from one process. References Core plus all three modules. Its `.csproj` builds and embeds the admin SPA on publish.
- `src/SlpModularCms.Api.Slave` — Second host used locally to represent an instance **without** the Master module (Core + Identity + Availability only). Runs on port 7222 against its own database.
## Application Packages
- `SlpModularCms.Api` — Web API host; bootstraps application, registers modules, exposes HTTP endpoints
- `SlpModularCms.Modules.Identity` — Identity module: authentication, setup, user invitation controllers
- `SlpModularCms.Modules.Availability` — Availability module: system status tracking controllers and services
## Shared Packages
- `SlpModularCms.Core` — Core domain: entities, DbContext, services, module interface, migrations
- `src/SlpModularCms.Core` — Shared foundation: Identity entities and services, hierarchical authorization, `IModule` + `ModuleOrchestrator`, `ApiPrefixConvention`, `GlobalExceptionHandler` and typed exceptions, the availability contract, `SystemController`, and `ApplicationDbContext` with 5 migrations (applied manually only).
### Modules (nested under Application)
- `src/SlpModularCms.Modules.Identity` — Auth, setup, invitation and user controllers. No persistence of its own.
- `src/SlpModularCms.Modules.Availability` — The availability gate: `AvailabilityMiddleware`, `PersistentAvailabilityService`, `AvailabilityDbContext` (`MasterRegistration`, 5 migrations, self-applied), master registration/status endpoints, `MasterStatusPollingBackgroundService` with fail-open, Data Protectionencrypted master API key.
- `src/SlpModularCms.Modules.Master` — Central control of other instances: `MasterDbContext` (`CmsInstance`, 3 migrations, self-applied), Owner-only `CmsInstanceController`, anonymous `SlaveStatusController`, `SlaveApiClient` with retry/timeout resilience, `ApiKeyProtector`, `IntegrityCheckBackgroundService`.
## Frontend Packages
- `frontend/` — The CMS admin SPA (Vite 8, React 19, TypeScript, TanStack Router/Query, Tailwind v4, shadcn-style components on Radix, react-i18next NL/EN, MSW). Deliberately outside `src/` so it stays out of the .NET solution. Built with `base: '/admin/'` and copied into the API's `wwwroot/admin/` at publish time. Not a solution project.
## Infrastructure Packages
**None.** There are no CDK, Terraform, CloudFormation, Docker or Kubernetes artifacts in the repository, and no CI/CD pipeline definitions (`.gitea/` does not exist). This is intentional: the deployment target is shared hosting where no server configuration is possible, so the application is designed to need none.
## Test Packages
- `SlpModularCms.Core.Tests` — Unit tests for Core layer (exceptions, identity services)
- `SlpModularCms.Modules.Availability.Tests` — Unit tests for Availability module
## Frontend (To Be Built)
- `SlpModularCms.Frontend` — React SPA; admin panel for CMS management
- `src/SlpModularCms.Core.Tests` — Unit (7 files): Exceptions, Hosting, Identity.
- `src/SlpModularCms.Modules.Identity.Tests` — Unit (4 files): Controllers.
- `src/SlpModularCms.Modules.Availability.Tests` — Unit (10 files): Controllers, Services, Repositories, BackgroundServices.
- `src/SlpModularCms.Modules.Master.Tests` — Unit (7 files): Controllers, Services, Repositories, BackgroundServices.
- `frontend/src/**/*.test.ts(x)` — 34 Vitest files colocated with the code under test, using Testing Library and MSW. Not a separate package.
`SlpModularCms.Api.Slave` has no test project by design (documented in `CLAUDE.md`). `SlpModularCms.Api` has no test project either — its `Program.cs` is composition only, with a `Program.Coverage.cs` partial supporting coverage collection.
## Total Count
- **Total Packages**: 6 (5 existing .NET + 1 new frontend)
- **Application**: 3 (Api, Modules.Identity, Modules.Availability)
- **Shared**: 1 (Core)
- **Test**: 2 (Core.Tests, Modules.Availability.Tests)
- **Frontend**: 1 (to be built)
- **Total .NET projects in the solution**: 10
- **Clients (deployable)**: 2 — `Api`, `Api.Slave`
- **Application**: 4 — `Core`, `Modules.Identity`, `Modules.Availability`, `Modules.Master`
- **Test**: 4 — `Core.Tests`, `Modules.Identity.Tests`, `Modules.Availability.Tests`, `Modules.Master.Tests`
- **Infrastructure**: 0
- **Non-solution packages**: 1 — `frontend/` (admin SPA)
## Approximate Size
- C# source files (excluding `bin`/`obj`): 122
- Of which EF Core migration files: 13 (5 Core, 5 Availability, 3 Master)
- TypeScript/TSX files under `frontend/src`: 111, of which 34 are tests
@@ -1,118 +1,187 @@
# Dependencies
# Dependencies
## Internal Dependencies
```mermaid
graph TD
Api["SlpModularCms.Api"]
Core["SlpModularCms.Core"]
ModIdentity["SlpModularCms.Modules.Identity"]
ModAvail["SlpModularCms.Modules.Availability"]
CoreTests["SlpModularCms.Core.Tests"]
AvailTests["SlpModularCms.Modules.Availability.Tests"]
Frontend["SlpModularCms.Frontend\n(to be built)"]
api["SlpModularCms.Api<br/>Client"]
slave["SlpModularCms.Api.Slave<br/>Client"]
core["SlpModularCms.Core"]
mid["Modules.Identity"]
mav["Modules.Availability"]
mma["Modules.Master"]
tcore["Core.Tests"]
tid["Modules.Identity.Tests"]
tav["Modules.Availability.Tests"]
tma["Modules.Master.Tests"]
fe["frontend<br/>admin SPA"]
Api -->|compile| Core
Api -->|compile| ModIdentity
Api -->|compile| ModAvail
ModIdentity -->|compile| Core
ModAvail -->|compile| Core
CoreTests -->|test| Core
AvailTests -->|test| ModAvail
AvailTests -->|test| Core
Frontend -->|runtime REST| Api
api --> core
api --> mid
api --> mav
api --> mma
slave --> core
slave --> mid
slave --> mav
mid --> core
mav --> core
mma --> core
tcore --> core
tid --> mid
tav --> mav
tma --> mma
fe -->|"REST at runtime"| api
api -->|"pnpm build at publish"| fe
style Api fill:#4CAF50,stroke:#2E7D32,color:#fff
style Core fill:#FFC107,stroke:#F57F17,color:#000
style ModIdentity fill:#4CAF50,stroke:#2E7D32,color:#fff
style ModAvail fill:#4CAF50,stroke:#2E7D32,color:#fff
style CoreTests fill:#9E9E9E,stroke:#424242,color:#fff
style AvailTests fill:#9E9E9E,stroke:#424242,color:#fff
style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff
classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
class api,slave client;
class core corelayer;
class mid,mav,mma module;
class tcore,tid,tav,tma test;
class fe frontend;
```
Text alternative: Both host projects reference Core and their modules; every module references only Core; each test project targets its own production project; the admin SPA calls the API at runtime and is itself built by the API project at publish time — a bidirectional coupling between backend and frontend.
### Dependency Details
#### SlpModularCms.Api depends on SlpModularCms.Core
#### `SlpModularCms.Api` → `SlpModularCms.Core`
- **Type**: Compile
- **Reason**: Needs ApplicationDbContext, entities, DI extensions, module registration
- **Reason**: `AddCoreInfrastructure`, `AddCmsCors`, `AddCmsRateLimiting`, `ModuleOrchestrator`, `ApiPrefixConvention`.
#### SlpModularCms.Api depends on SlpModularCms.Modules.Identity
#### `SlpModularCms.Api` → `Modules.Identity`, `Modules.Availability`, `Modules.Master`
- **Type**: Compile (present to force the module DLLs into the output directory)
- **Reason**: `ModuleOrchestrator` discovers modules by globbing `SlpModularCms.Modules.*.dll` in the app base directory, so a project reference is how a module ends up deployed. The code itself does not call into the modules directly. **Consequence for deployment: which modules an instance has is determined by which DLLs the published output contains.**
#### `SlpModularCms.Api.Slave` → `Core`, `Modules.Identity`, `Modules.Availability`
- **Type**: Compile
- **Reason**: Registers Identity module and its HTTP controllers
- **Reason**: Same as above, minus the Master module — the omission is the entire point of this host.
#### SlpModularCms.Api depends on SlpModularCms.Modules.Availability
#### `Modules.Identity` → `Core`
- **Type**: Compile
- **Reason**: Registers Availability module and its HTTP controllers
- **Reason**: Identity entities, `IAuthService`, `IInvitationService`, `ISetupService`, request/response models, authorization policies.
#### SlpModularCms.Modules.Identity depends on SlpModularCms.Core
#### `Modules.Availability` → `Core`
- **Type**: Compile
- **Reason**: Uses domain entities (ApplicationUser, Invitation), services (IAuthService), and DbContext
- **Reason**: `IAvailabilityService`, `AvailabilityStatus`, `AvailabilityOptions`, `MasterControlledAvailabilityException`, `IModule`.
#### SlpModularCms.Modules.Availability depends on SlpModularCms.Core
#### `Modules.Master` → `Core`
- **Type**: Compile
- **Reason**: Uses GlobalAvailabilityState, AvailabilityStatus, AvailabilityOptions
- **Reason**: `IModule`, authorization policies, shared exception types.
## External Dependencies (Backend)
#### Test projects → their production project (and transitively `Core`)
- **Type**: Test
- **Reason**: Each suite mirrors one production project, per the solution-folder rules in `CLAUDE.md`.
### Microsoft.AspNetCore.Identity
- **Version**: .NET 10 built-in
- **Purpose**: User and role management, password hashing
- **License**: MIT
#### `frontend` → `SlpModularCms.Api`
- **Type**: Runtime (HTTP/REST)
- **Reason**: All data comes from `/api/v1/**` at `VITE_API_BASE_URL`, with credentials so the refresh cookie travels.
### Microsoft.EntityFrameworkCore + SqlServer provider
- **Version**: .NET 10 built-in
- **Purpose**: Data persistence
- **License**: MIT
#### `SlpModularCms.Api` → `frontend` (build-time, reverse direction)
- **Type**: Build
- **Reason**: The `BuildAndCopyAdminFrontend` MSBuild target runs `pnpm install --frozen-lockfile` and `pnpm build` in `frontend/` before publish and copies `dist/**` into `wwwroot/admin/`. **This makes Node and pnpm hard prerequisites of `dotnet publish` on any build agent.**
### Microsoft.AspNetCore.Authentication.JwtBearer
- **Version**: .NET 10 built-in
- **Purpose**: JWT authentication middleware
- **License**: MIT
### Cross-instance runtime dependencies
### Microsoft.IdentityModel.Tokens
- **Version**: .NET 10 built-in
- **Purpose**: JWT token creation and validation
- **License**: MIT
Not project references, but real coupling between deployed instances:
## External Dependencies (Frontend — from package.json)
- A **Master** instance calls each registered slave's `POST /api/v1/master/register` and `POST /api/v1/master/status`, authenticated with `X-Master-Api-Key`, through a resilience pipeline (2 retries, exponential backoff with jitter, configurable timeout).
- Each **slave** calls its Master's `GET /api/v1/SlaveStatus` on a timer (`MasterPolling:PollIntervalSeconds`), and fails open to Available after `MasterPolling:FailOpenAfterMinutes` of unreachability.
- Both directions require the two instances to be reachable over HTTP from one another, which is a deployment/network consideration rather than a code one.
### react + react-dom
- **Version**: 18.3.1
- **Purpose**: Core UI framework
- **License**: MIT
## External Dependencies
### react-router
- **Version**: 7.13.0
- **Purpose**: Client-side routing
- **License**: MIT
### Backend — `SlpModularCms.Core`
### @radix-ui/* (multiple packages)
- **Version**: Various (1.x2.x)
- **Purpose**: shadcn/ui component primitives
- **License**: MIT
| Package | Version | Purpose | License |
|---|---|---|---|
| `Microsoft.EntityFrameworkCore.SqlServer` | 10.0.9 | SQL Server persistence | MIT |
| `Microsoft.AspNetCore.Identity.EntityFrameworkCore` | 10.0.9 | Users, roles, password hashing | MIT |
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.9 | Bearer token validation | MIT |
| `Microsoft.AspNetCore.OpenApi` | 10.0.9 | OpenAPI document generation | MIT |
| `Asp.Versioning.Mvc` | 10.0.0 | API version reporting | MIT |
| `Microsoft.Extensions.DependencyInjection.Abstractions` | 10.0.9 | DI abstractions | MIT |
| `Microsoft.Extensions.Hosting.Abstractions` | 10.0.9 | Hosting abstractions | MIT |
| `Microsoft.Extensions.Logging.Abstractions` | 10.0.9 | Logging abstractions | MIT |
| `FrameworkReference: Microsoft.AspNetCore.App` | net10.0 | Lets a class library use ASP.NET Core types, incl. Data Protection | MIT |
### tailwindcss
- **Version**: 4.1.12
- **Purpose**: Utility-first CSS framework
- **License**: MIT
### Backend — `SlpModularCms.Api`
### lucide-react
- **Version**: 0.487.0
- **Purpose**: Icon library
- **License**: ISC
| Package | Version | Purpose | License |
|---|---|---|---|
| `Asp.Versioning.Mvc` | 10.0.0 | API versioning | MIT |
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.9 | Bearer auth | MIT |
| `Microsoft.AspNetCore.OpenApi` | 10.0.9 | OpenAPI | MIT |
| `Microsoft.EntityFrameworkCore.Design` | 10.0.9 (PrivateAssets) | `dotnet ef` tooling | MIT |
| `Scalar.AspNetCore` | 2.16.3 | `/scalar` API reference, Development only | MIT |
### recharts
- **Version**: 2.15.2
- **Purpose**: Charts and data visualization
- **License**: MIT
`SlpModularCms.Api.Slave` carries `Microsoft.EntityFrameworkCore.Design` and `Scalar.AspNetCore` at the same versions.
### react-hook-form
- **Version**: 7.55.0
- **Purpose**: Form state management
- **License**: MIT
### Backend — modules
### sonner
- **Version**: 2.0.3
- **Purpose**: Toast notifications
- **License**: MIT
| Project | Package | Version | Purpose | License |
|---|---|---|---|---|
| `Modules.Master` | `Microsoft.Extensions.Http.Resilience` | 9.6.0 | Retry/timeout for master→slave calls (pulls in Polly) | MIT |
| `Modules.Availability` | — | — | No external packages beyond Core's transitives | — |
| `Modules.Identity` | — | — | No external packages beyond Core's transitives | — |
**Version note**: `Microsoft.Extensions.Http.Resilience` 9.6.0 is a 9.x package on `net10.0` targets. It works, but it is the one dependency out of step with the otherwise uniform 10.0.x line — worth pinning deliberately rather than by accident in any CI setup.
### Backend — test projects (all four, identical set)
| Package | Version | Purpose | License |
|---|---|---|---|
| `Microsoft.NET.Test.Sdk` | 17.14.1 | Test host | MIT |
| `xunit` | 2.9.3 | Test framework | Apache-2.0 |
| `xunit.runner.visualstudio` | 3.1.4 | Test adapter | Apache-2.0 |
| `FluentAssertions` | 8.10.0 | Assertions | Dual: free for non-commercial / paid commercial from v8 — **worth verifying against how this project is used** |
| `NSubstitute` | 5.3.0 | Mocking | BSD-3-Clause |
| `AutoFixture` | 4.18.1 | Test data generation | MIT |
| `Microsoft.EntityFrameworkCore.InMemory` | 10.0.9 | In-memory provider for tests | MIT |
| `coverlet.collector` | 6.0.4 | Coverage collection | MIT |
### Frontend — runtime (`frontend/package.json` dependencies)
| Package | Version | Purpose | License |
|---|---|---|---|
| `react`, `react-dom` | ^19.2.6 | UI framework | MIT |
| `@tanstack/react-router` | ^1.170.16 | Routing (honours `BASE_URL`, so `/admin` works) | MIT |
| `@tanstack/react-query` | ^5.101.0 | Server state | MIT |
| `@radix-ui/react-{dialog,dropdown-menu,label,select,slot}` | 1.x2.x | Accessible primitives | MIT |
| `react-hook-form` | ^7.79.0 | Forms | MIT |
| `@hookform/resolvers` | ^5.4.0 | Validation bridge | MIT |
| `zod` | ^4.4.3 | Schema validation (also validates app config) | MIT |
| `i18next`, `react-i18next`, `i18next-browser-languagedetector` | 26 / 17 / 8 | NL/EN localisation | MIT |
| `lucide-react` | ^1.21.0 | Icons | ISC |
| `sonner` | ^2.0.7 | Toasts | MIT |
| `class-variance-authority`, `clsx`, `tailwind-merge` | 0.7 / 2.1 / 3.6 | Class composition | MIT |
### Frontend — build and test (devDependencies)
| Package | Version | Purpose | License |
|---|---|---|---|
| `vite` | ^8.0.12 | Build tool / dev server | MIT |
| `@vitejs/plugin-react` | ^6.0.1 | React support | MIT |
| `typescript` | ~6.0.2 | Type checking (`tsc -b` gates the build) | Apache-2.0 |
| `tailwindcss`, `@tailwindcss/vite` | ^4.3.1 | Styling | MIT |
| `vitest`, `@vitest/coverage-v8` | ^4.1.9 | Tests and coverage | MIT |
| `jsdom` | ^29.1.1 | DOM for tests | MIT |
| `@testing-library/{react,jest-dom,user-event}` | 16.3 / 6.9 / 14.6 | Component testing | MIT |
| `msw` | ^2.14.6 | Request mocking | MIT |
| `eslint`, `typescript-eslint`, `eslint-plugin-react-hooks`, `eslint-plugin-react-refresh`, `@eslint/js`, `globals` | 10 / 8.59 / 7.1 / 0.5 / 10 / 17.6 | Linting | MIT |
| `prettier` | ^3.8.4 | Formatting | MIT |
| `concurrently` | ^9.1.2 | Runs `dev:all` (master + slave dev servers) | MIT |
| `@types/{node,react,react-dom}` | 24 / 19.2 / 19.2 | Type definitions | MIT |
### Lockfiles and reproducibility
- `frontend/pnpm-lock.yaml` exists, and the publish target uses `--frozen-lockfile`, so frontend installs are reproducible.
- There is **no `packages.lock.json`** for any .NET project — NuGet restore is not locked, so `dotnet restore` can resolve differently over time on floating transitive versions. Relevant if reproducible CI builds matter.
### Dependencies that are absent but expected by this feature
No package currently provides uptime, analytics, error tracking or structured logging. Adding **Sentry** (a `Sentry.AspNetCore` package on the backend and `@sentry/react` on the frontend) and **Umami** (a script tag, no package) would be new dependencies; **UptimeRobot** is external and needs only an HTTP endpoint to probe.
@@ -1,9 +1,19 @@
# Reverse Engineering Metadata
# Reverse Engineering Metadata
**Analysis Date**: 2026-06-16T20:30:00Z
**Analyzer**: AI-DLC (Junie)
**Analysis Date**: 2026-07-27T00:00:00Z
**Analyzer**: AI-DLC (Claude Code)
**Workspace**: K:\Development\Projects\SlpModularCms
**Total Files Analyzed**: ~35 (backend .cs files) + ~60 (frontend .tsx/.ts files from ZIP)
**Git branch / HEAD at analysis**: `feature/gitea-deployment-workflow` (branched from `master` at `3885703`)
**Total Files Analyzed**: 122 C# files (excluding `bin`/`obj`) + 111 TypeScript/TSX files under `frontend/src` + solution, project, configuration and lock files
**Trigger**: Full rerun requested by the user during the `gitea-deployment-workflow` feature. The previous artifacts (2026-06-16) predated the Master module, the `SlpModularCms.Api.Slave` host, the solution-folder reorganisation (`754bd97`) and single-host serving (`3885703`) — all deployment-relevant.
**Verification performed** (measured, not inferred):
- `dotnet build SlpModularCms.sln -c Release` — 0 errors, 50 warnings
- `dotnet test SlpModularCms.sln -c Release` — 219 tests, all passed
- `cd frontend && pnpm test` — 34 files, 213 tests, all passed
- `cd frontend && pnpm run lint`**fails**: 5 errors, 1 warning
- `dotnet list package --vulnerable --include-transitive` — 2 high-severity transitive advisories
## Artifacts Generated
- [x] business-overview.md
@@ -14,3 +24,6 @@
- [x] technology-stack.md
- [x] dependencies.md
- [x] code-quality-assessment.md
## Previous Analysis
Superseded: 2026-06-16T20:30:00Z by AI-DLC (Junie), ~35 backend + ~60 frontend files. Prior versions remain retrievable from git history.
@@ -1,56 +1,80 @@
# Technology Stack
# Technology Stack
## Backend
### Programming Languages
- C# 14.0All backend packages
- C# (latest for `net10.0`)all backend projects. `Nullable` and `ImplicitUsings` enabled everywhere.
### Frameworks
- ASP.NET Core 10.0 — Web API framework
- ASP.NET Core Identity — User/role management, password hashing
- Entity Framework Core 10.0 — ORM for SQL Server persistence
- .NET / ASP.NET Core `net10.0`host, MVC controllers, middleware pipeline, static-file serving with SPA fallbacks. SDK observed: **10.0.301**.
- ASP.NET Core Identity (`Microsoft.AspNetCore.Identity.EntityFrameworkCore` 10.0.9)users, roles, password hashing and policy.
- Entity Framework Core (`Microsoft.EntityFrameworkCore.SqlServer` 10.0.9, `…Design` 10.0.9) — three `DbContext` types over one connection string.
- `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.9 — bearer token validation, `ClockSkew.Zero`.
- `Asp.Versioning.Mvc` 10.0.0 — API version reporting alongside the static `/api/v1` prefix convention.
- `Microsoft.AspNetCore.OpenApi` 10.0.9 + `Scalar.AspNetCore` 2.16.3 — OpenAPI document and `/scalar` reference UI, **Development only**.
- `Microsoft.Extensions.Http.Resilience` 9.6.0 (with Polly) — retry/timeout pipeline for master→slave HTTP calls. Note: a 9.x package on a `net10.0` target.
- ASP.NET Core Data Protection (shared framework) — encrypts slave API keys. Default file-system key ring; **no persistent key store configured**.
- Built-in rate limiting (`Microsoft.AspNetCore.RateLimiting`) — fixed-window `login`, sliding-window `refresh`.
### Infrastructure
- SQL Server — Primary database
- JWT Bearer Authentication — Stateless auth with refresh tokens
- SQL Server — one database per instance. Local development via a container (`mcr.microsoft.com/mssql/server:2022-latest`) or LocalDB.
- No cloud services, message broker, cache server or container orchestration is used.
- Deployment target: shared hosting (e.g. mijnhostingpartner.nl) with a single site/application pool and **no server configuration**. The web SDK generates `web.config` on publish for IIS-based hosting.
### Build Tools
- .NET 10 SDK / dotnet CLI — Build, test, publish
- MSBuild — Underlying build engine
- .NET SDK 10 / `dotnet` CLI — build, test, publish.
- MSBuild — including the custom `BuildAndCopyAdminFrontend` target in `SlpModularCms.Api.csproj`, which makes **Node and pnpm hard prerequisites of `dotnet publish`**.
- `dotnet ef` — migration authoring; per-module contexts need `--context` disambiguation (`AvailabilityDbContext`).
### Testing Tools
- xUnit (inferred from project conventions) — Unit testing framework
- Moq or similar (inferred) — Mocking in unit tests
- xUnit 2.9.3 with `xunit.runner.visualstudio` 3.1.4 and `Microsoft.NET.Test.Sdk` 17.14.1.
- FluentAssertions 8.10.0 — assertions.
- NSubstitute 5.3.0 — mocking.
- AutoFixture 4.18.1 — test data.
- `Microsoft.EntityFrameworkCore.InMemory` 10.0.9 — in-memory persistence for tests.
- coverlet 6.0.4 (`coverlet.collector`) with `coverlet.runsettings` at the repository root.
---
## Frontend (Example App — ZIP file basis)
## Frontend (admin SPA, `frontend/`)
### Programming Languages
- TypeScript — All frontend code
- TypeScript `~6.0.2`all frontend code.
### Frameworks
- React 18.3.1 — UI framework
- TanStack Router — Client-side routing (replaces React Router v7 from example app; chosen for full TypeScript safety and modern routing features)
- Tailwind CSS v4 (4.1.12)Utility-first CSS framework
- shadcn/ui (via Radix UI) — Accessible component primitives
### UI Component Libraries
- Radix UI — Headless component primitives (accordion, dialog, dropdown, etc.)
- lucide-react (0.487.0) — SVG icon library
- recharts (2.15.2) — Charts and data visualization
- MUI / Material UI (7.3.5) — Additional UI components
### State / Data
- react-hook-form (7.55.0) — Form state management
- sonner (2.0.3) — Toast notifications
- next-themes (0.4.6) — Dark/light theme support
### Frameworks and Libraries
- React 19.2 + React DOM 19.2.
- Vite 8.0 with `@vitejs/plugin-react` 6 — build and dev server. `base: '/admin/'` on build only.
- TanStack Router 1.170routing, `basepath: import.meta.env.BASE_URL` so it follows the `/admin/` base.
- TanStack React Query 5.101 — server state.
- Tailwind CSS 4.3 via `@tailwindcss/vite` — styling.
- Radix UI primitives (dialog, dropdown-menu, label, select, slot) with shadcn-style wrappers; `class-variance-authority`, `clsx`, `tailwind-merge`.
- `lucide-react` 1.21 — icons. `sonner` 2.0 — toasts.
- `react-hook-form` 7.79 with `@hookform/resolvers` 5.4 and `zod` 4.4 — forms and validation. Zod also validates app config.
- `i18next` 26 / `react-i18next` 17 / `i18next-browser-languagedetector` 8 — NL/EN.
### Build Tools
- Vite 6.3.5 — Build tool and dev server
- pnpm — Package manager (pnpm-workspace.yaml present)
- PostCSS — CSS processing
- pnpm — package manager. Observed locally: pnpm 10.33.2, Node v22.15.1. (The README states Node 20+ and pnpm 9+ as the requirement.)
- `tsc -b` runs before `vite build`, so type errors fail the build.
### Theme
- Primary color: `#ac0000` (deep red)
- Mode: Light + dark via CSS custom properties
### Testing Tools
- Vitest 4.1 with `@vitest/coverage-v8` and jsdom 29.
- Testing Library: `@testing-library/react` 16.3, `jest-dom` 6.9, `user-event` 14.6.
- MSW 2.14 — request mocking in tests, and optionally in the browser via `VITE_ENABLE_MSW=true`.
### Linting and Formatting
- ESLint 10 with `typescript-eslint` 8.59, `eslint-plugin-react-hooks` 7, `eslint-plugin-react-refresh` 0.5.
- Prettier 3.8 (`format`, `format:check` scripts, 4-space indent).
- No linter or analyzer configuration exists for the backend beyond compiler nullable warnings.
## Observability
**Not implemented.** The stack currently has:
- Logging: default ASP.NET Core console provider only, configured through `Logging:LogLevel` (`Warning` in the production baseline, `Information` in Development). No structured logging, no log sink, no correlation IDs.
- Error tracking: none — no Sentry package on either side.
- Analytics: none — no Umami script or equivalent.
- Uptime/health: no health-check endpoint, no `MapHealthChecks`.
- Metrics/tracing: no OpenTelemetry.
The intended stack for this feature — **UptimeRobot** for uptime, **Umami** for analytics, **console logging plus Sentry** for logging and errors — is therefore entirely greenfield in this repository. A working reference implementation of the Umami and Sentry parts (for a React/Vite frontend) exists in `K:\Development\SlpSoftware\Projects\SlpSoftware`.
## Environments
Three environments are in scope: **local**, **test** and **production**. Configuration follows the three-file appsettings pattern (`appsettings.json`, `appsettings.Development.json`, gitignored `appsettings.local.json`), with production secrets supplied as environment variables using the `Section__Key` convention. There is currently no `appsettings.Test.json` or equivalent, and no `ASPNETCORE_ENVIRONMENT` value defined for a test environment.