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:
@@ -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
|
## REST APIs
|
||||||
|
|
||||||
### Authentication
|
### Authentication — `Modules.Identity/AuthController` (`/api/v1/auth`)
|
||||||
|
|
||||||
#### POST /auth/login
|
#### Login
|
||||||
- **Method**: POST
|
- **Method**: POST
|
||||||
- **Path**: `/auth/login`
|
- **Path**: `/api/v1/auth/login`
|
||||||
- **Purpose**: Authenticate a user and receive JWT tokens
|
- **Purpose**: Authenticate a user and start a session.
|
||||||
- **Authorization**: Anonymous
|
- **Auth**: Anonymous. Rate limiter `login`.
|
||||||
- **Request**: `{ "email": string, "password": string }`
|
- **Request**: `LoginRequest { email, password }`
|
||||||
- **Response**: `{ "accessToken": string, "expiresAt": datetime, "user": { "id": guid, "email": string, "name": string, "role": string, "isActive": bool } }`
|
- **Response**: `200` `TokenResponse { accessToken, expiresAt, user { id, email, name, role, isActive } }` plus a `refreshToken` cookie.
|
||||||
- **Cookie set**: `refreshToken` (httpOnly, Secure, SameSite=Strict, Path=/api/v1/auth)
|
|
||||||
|
|
||||||
#### POST /auth/refresh
|
#### Refresh
|
||||||
- **Method**: POST
|
- **Method**: POST
|
||||||
- **Path**: `/auth/refresh`
|
- **Path**: `/api/v1/auth/refresh`
|
||||||
- **Purpose**: Refresh an access token using the httpOnly refresh token cookie
|
- **Purpose**: Rotate the refresh token and issue a new access token (used for silent refresh on SPA startup).
|
||||||
- **Authorization**: Anonymous
|
- **Auth**: Anonymous — authority comes from the cookie. Rate limiter `refresh`.
|
||||||
- **Request**: (empty body — refresh token read from cookie)
|
- **Request**: No body; reads the `refreshToken` cookie.
|
||||||
- **Response**: Same as `/auth/login` (new access token + new cookie)
|
- **Response**: `200` `TokenResponse` plus a replaced cookie; `401` when the cookie is missing or invalid.
|
||||||
|
|
||||||
#### POST /auth/revoke
|
#### Revoke
|
||||||
- **Method**: POST
|
- **Method**: POST
|
||||||
- **Path**: `/auth/revoke`
|
- **Path**: `/api/v1/auth/revoke`
|
||||||
- **Purpose**: Revoke a refresh token (logout)
|
- **Purpose**: Log out — revoke the refresh token and clear the cookie.
|
||||||
- **Authorization**: Bearer JWT required
|
- **Auth**: Anonymous (the cookie carries the authority).
|
||||||
- **Request**: `"<refreshToken>"` (string body)
|
- **Response**: `200`.
|
||||||
- **Response**: 204 No Content
|
|
||||||
|
|
||||||
---
|
#### 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
|
- **Method**: GET
|
||||||
- **Path**: `/setup/status`
|
- **Path**: `/api/v1/Setup/status`
|
||||||
- **Purpose**: Check if the system has been initialized (first owner created)
|
- **Purpose**: Tell a client whether the system still needs bootstrapping. On the availability bypass list.
|
||||||
- **Authorization**: Anonymous
|
- **Auth**: Anonymous.
|
||||||
- **Response**: `{ "initialized": boolean }`
|
- **Response**: `200` `{ initialized: bool }`.
|
||||||
|
|
||||||
#### POST /setup/owner
|
#### Create initial owner
|
||||||
- **Method**: POST
|
- **Method**: POST
|
||||||
- **Path**: `/setup/owner`
|
- **Path**: `/api/v1/Setup/owner`
|
||||||
- **Purpose**: Create the initial Owner account (only usable when system is not yet initialized)
|
- **Purpose**: One-time creation of the first Owner account.
|
||||||
- **Authorization**: Anonymous
|
- **Auth**: Anonymous (only meaningful while uninitialized).
|
||||||
- **Request**: `{ "email": string, "password": string }`
|
- **Request**: `CreateOwnerRequest { name, email, password }`
|
||||||
- **Response**: `{ "message": string }`
|
- **Response**: `200` `{ message }`.
|
||||||
|
|
||||||
---
|
### Invitations — `Modules.Identity/InvitationController` (`/api/v1/Invitation`)
|
||||||
|
|
||||||
### Users
|
#### Validate invitation
|
||||||
|
|
||||||
#### 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
|
|
||||||
- **Method**: GET
|
- **Method**: GET
|
||||||
- **Path**: `/users/validate-invitation?token={token}`
|
- **Path**: `/api/v1/Invitation/validate?token={token}`
|
||||||
- **Purpose**: Validate an invitation token before showing the setup form
|
- **Purpose**: Check an invitation token before showing the registration form.
|
||||||
- **Authorization**: Anonymous
|
- **Auth**: Anonymous.
|
||||||
- **Response**: `{ "valid": boolean, "email": string, "role": string }` or error
|
- **Response**: `200` `{ isValid, email, name, errorCode }` — an invalid token is reported in the body (e.g. `errorCode: "NOT_FOUND"`), not as an error status.
|
||||||
|
|
||||||
---
|
#### Complete invitation
|
||||||
|
|
||||||
### 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
|
|
||||||
- **Method**: POST
|
- **Method**: POST
|
||||||
- **Path**: `/availability/admin/status`
|
- **Path**: `/api/v1/Invitation/complete`
|
||||||
- **Purpose**: Update the system availability status
|
- **Purpose**: Set a password and activate the invited account.
|
||||||
- **Authorization**: Bearer JWT, Policy: OwnerOnly
|
- **Auth**: Anonymous.
|
||||||
- **Request**: `{ "newStatus": "Available|Maintenance|Unavailable", "reason": string }`
|
- **Request**: `CompleteSetupRequest { token, password }`
|
||||||
- **Response**: 200 OK or 400 Bad Request
|
- **Response**: `200` `{ message }`.
|
||||||
|
|
||||||
---
|
### Users — `Modules.Identity/UsersController` (`/api/v1/Users`)
|
||||||
|
|
||||||
## Authorization Policies
|
Controller default policy: `AdminOnly`.
|
||||||
|
|
||||||
| Policy | Required Role | Description |
|
| Method | Path | Purpose | Auth | Request | Response |
|
||||||
|--------|--------------|-------------|
|
|---|---|---|---|---|---|
|
||||||
| `OwnerOnly` | Owner | Full system access including availability management |
|
| GET | `/api/v1/Users` | List users, including pending invitations | AdminOnly | — | `200` `UserDto[]` |
|
||||||
| `AdminOnly` | Owner or Admin | User management access |
|
| 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
|
## Data Models
|
||||||
|
|
||||||
### AuthResponse
|
### `ApplicationUser` (extends `IdentityUser<Guid>`)
|
||||||
- `accessToken` — short-lived JWT (e.g. 15 min)
|
- **Fields**: identity fields plus `Name`, `IsActive`, `CreatedAt`.
|
||||||
- `refreshToken` — long-lived opaque token
|
- **Relationships**: roles via Identity; `RefreshToken`s; `Invitation`s.
|
||||||
- `expiresAt` — access token expiry datetime
|
|
||||||
- `user` — authenticated user info
|
|
||||||
|
|
||||||
### Password Validation Rules (enforced by backend)
|
### `ApplicationRole` (extends `IdentityRole<Guid>`)
|
||||||
Configured in `ServiceCollectionExtensions.cs` via ASP.NET Core Identity `PasswordOptions`:
|
- **Fields**: identity role fields. Roles in use: `Owner`, `Administrator`, `User`.
|
||||||
- `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. `!@#$%^&*`)
|
|
||||||
|
|
||||||
### ApplicationUser (returned in auth responses)
|
### `RefreshToken`
|
||||||
- `id` — Guid
|
- **Fields**: token value, expiry, revocation state, owning user.
|
||||||
- `email` — string
|
- **Validation**: rotated on every refresh; the previous token is revoked.
|
||||||
- `name` — string (display name)
|
|
||||||
- `role` — string (Owner / Admin / User)
|
|
||||||
- `isActive` — boolean
|
|
||||||
|
|
||||||
### Invitation
|
### `Invitation`
|
||||||
- `token` — string (URL-safe token)
|
- **Fields**: token, target email, role, expiry, used flag.
|
||||||
- `email` — string
|
- **Validation**: single-use and time-limited; `InvitationOrUserAlreadyExistsException` guards duplicates.
|
||||||
- `role` — string
|
|
||||||
- `expiryDate` — datetime
|
### `ModulePermission`
|
||||||
- `isUsed` — boolean
|
- **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 Protection–encrypted; 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
|
## 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
|
## Architecture Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph ClientLayer["Client Layer"]
|
visitor["Public visitor"]
|
||||||
Frontend["React SPA\nVite + TanStack Router + shadcn/ui\nTailwind CSS v4 #ac0000"]
|
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
|
end
|
||||||
|
|
||||||
subgraph ApiLayer["API Layer"]
|
db[("SQL Server<br/>ApplicationDbContext<br/>AvailabilityDbContext<br/>MasterDbContext")]
|
||||||
Api["SlpModularCms.Api\nASP.NET Core\nJWT Bearer, CORS, Swagger"]
|
slaveinst["Slave CMS instances<br/>(separate deployments)"]
|
||||||
Identity["Identity Module\nAuthController\nSetupController\nUsersController"]
|
|
||||||
Avail["Availability Module\nAvailabilityController\nPersistentService + CircuitBreaker"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph CoreLayer["Core Layer"]
|
visitor --> static
|
||||||
Core["SlpModularCms.Core\nApplicationDbContext\nDomain Entities\nIdentity Services\nIModule interface"]
|
adminuser --> static
|
||||||
end
|
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"]
|
classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
DB[("SQL Server\nIdentity tables\nRefreshTokens\nInvitations\nGlobalAvailabilityState")]
|
classDef surface fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
end
|
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
|
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
Frontend -->|HTTP REST / JSON| Api
|
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
Api --> Identity
|
classDef external fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||||
Api --> Avail
|
class visitor,adminuser actor;
|
||||||
Identity --> Core
|
class static,pipeline,orchestrator surface;
|
||||||
Avail --> Core
|
class core corelayer;
|
||||||
Core --> DB
|
class modidentity,modavail,modmaster module;
|
||||||
|
class db store;
|
||||||
style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff
|
class slaveinst external;
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Component Descriptions
|
||||||
|
|
||||||
### SlpModularCms.Api
|
### SlpModularCms.Api
|
||||||
- **Purpose**: Web API host and application entry point
|
- **Purpose**: Deployable host — the single site that serves everything.
|
||||||
- **Responsibilities**: Bootstrap, module loading, middleware pipeline, CORS, Swagger
|
- **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**: SlpModularCms.Core, SlpModularCms.Modules.Identity, SlpModularCms.Modules.Availability
|
- **Dependencies**: Core, Modules.Identity, Modules.Availability, Modules.Master.
|
||||||
- **Type**: Application
|
- **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
|
### SlpModularCms.Core
|
||||||
- **Purpose**: Shared domain layer
|
- **Purpose**: Shared foundation.
|
||||||
- **Responsibilities**: Domain entities, EF Core DbContext, authentication services, module interface
|
- **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, ASP.NET Identity, SQL Server provider
|
- **Dependencies**: EF Core + SQL Server provider, ASP.NET Core Identity, JwtBearer, Asp.Versioning, OpenAPI. References the ASP.NET Core shared framework.
|
||||||
- **Type**: Shared Library
|
- **Type**: Shared library.
|
||||||
|
|
||||||
### SlpModularCms.Modules.Identity
|
### SlpModularCms.Modules.Identity
|
||||||
- **Purpose**: Identity and user management module
|
- **Purpose**: HTTP surface for accounts and access.
|
||||||
- **Responsibilities**: HTTP endpoints for auth, setup, and user invitation flows
|
- **Responsibilities**: `AuthController`, `SetupController`, `InvitationController`, `UsersController`. Holds no persistence of its own.
|
||||||
- **Dependencies**: SlpModularCms.Core
|
- **Dependencies**: Core.
|
||||||
- **Type**: Application Module
|
- **Type**: Application module.
|
||||||
|
|
||||||
### SlpModularCms.Modules.Availability
|
### SlpModularCms.Modules.Availability
|
||||||
- **Purpose**: System availability tracking module
|
- **Purpose**: Decides whether this instance serves requests.
|
||||||
- **Responsibilities**: Exposes system status, allows owners to update it, caches with circuit breaker
|
- **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 Protection–encrypted master API key.
|
||||||
- **Dependencies**: SlpModularCms.Core
|
- **Dependencies**: Core.
|
||||||
- **Type**: Application Module
|
- **Type**: Application module. Self-migrates at startup.
|
||||||
|
|
||||||
### SlpModularCms.Frontend (To Be Built)
|
### SlpModularCms.Modules.Master
|
||||||
- **Purpose**: Admin SPA for CMS management
|
- **Purpose**: Central control point over other instances.
|
||||||
- **Responsibilities**: Login, dashboard, user management, CMS content management, availability status display
|
- **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**: SlpModularCms.Api (REST)
|
- **Dependencies**: Core, `Microsoft.Extensions.Http.Resilience`.
|
||||||
- **Type**: Frontend Application
|
- **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
|
## Data Flow
|
||||||
|
|
||||||
|
### Login and silent refresh
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Browser
|
box rgba(246,224,94,0.4) Client
|
||||||
participant AuthController
|
participant B as Browser (admin SPA)
|
||||||
participant AuthService
|
end
|
||||||
participant DB
|
box rgba(99,179,237,0.4) Host
|
||||||
|
participant A as AuthController
|
||||||
Note over Browser,DB: Login Flow
|
participant S as AuthService
|
||||||
Browser->>AuthController: POST /auth/login
|
end
|
||||||
AuthController->>AuthService: AuthenticateAsync()
|
box rgba(214,188,250,0.4) Data
|
||||||
AuthService->>DB: Validate credentials
|
participant D as SQL Server
|
||||||
DB-->>AuthService: User found
|
end
|
||||||
AuthService-->>AuthController: access + refresh tokens
|
B->>A: POST /api/v1/auth/login
|
||||||
AuthController-->>Browser: 200 OK with tokens
|
A->>S: authenticate credentials
|
||||||
|
S->>D: verify user and persist refresh token
|
||||||
Note over Browser,DB: Invite Flow
|
D-->>S: ok
|
||||||
Browser->>AuthController: POST /users/invite
|
S-->>A: access token plus refresh token
|
||||||
AuthController->>AuthService: CreateInvitationAsync()
|
A-->>B: 200 with access token, refresh cookie set
|
||||||
AuthService->>DB: Store Invitation entity
|
B->>A: POST /api/v1/auth/refresh on startup
|
||||||
DB-->>AuthService: Stored
|
A->>S: rotate refresh token
|
||||||
AuthService-->>AuthController: invite token
|
S->>D: revoke old and store new
|
||||||
AuthController-->>Browser: 200 OK with invite link
|
D-->>S: ok
|
||||||
|
A-->>B: 200 with new access token and cookie
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Integration Points
|
||||||
- **External APIs**: None currently
|
|
||||||
- **Databases**: SQL Server (via EF Core)
|
- **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.
|
||||||
- **Third-party Services**: None currently
|
- **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
|
## 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)
|
- **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).
|
||||||
- **Database Migrations**: EF Core Code-First migrations in SlpModularCms.Core/Migrations/
|
- **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
|
## Business Context Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Platform["SlpModularCms Platform"]
|
owner["Owner<br/>(system owner)"]
|
||||||
Identity["Identity Module\n(Auth + Users)"]
|
admin["Administrator"]
|
||||||
CMS["CMS Module\n(Content Mgmt)"]
|
enduser["User"]
|
||||||
Availability["Availability Module\n(System Status)"]
|
visitor["Public website visitor"]
|
||||||
Core["Core / Shell\n(Domain entities, DbContext, Module I/F)"]
|
cms["SlpModularCms instance<br/>(single host process)"]
|
||||||
end
|
slave["Other CMS instances<br/>(slaves)"]
|
||||||
|
db[("SQL Server<br/>database")]
|
||||||
|
|
||||||
Identity --> Core
|
owner --> cms
|
||||||
CMS --> Core
|
admin --> cms
|
||||||
Availability --> Core
|
enduser --> cms
|
||||||
|
visitor --> cms
|
||||||
|
cms --> db
|
||||||
|
cms -->|"pushes availability status"| slave
|
||||||
|
slave -->|"polls own status"| cms
|
||||||
|
|
||||||
Platform --> AdminFrontend["Admin Frontend\n(React SPA)"]
|
classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
Platform --> ExternalClients["External Clients\n(API consumers)"]
|
classDef system fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
classDef external fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
style Identity fill:#4CAF50,stroke:#2E7D32,color:#fff
|
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
style CMS fill:#4CAF50,stroke:#2E7D32,color:#fff
|
class owner,admin,enduser,visitor actor;
|
||||||
style Availability fill:#4CAF50,stroke:#2E7D32,color:#fff
|
class cms system;
|
||||||
style Core fill:#FFC107,stroke:#F57F17,color:#000
|
class slave external;
|
||||||
style AdminFrontend fill:#2196F3,stroke:#0D47A1,color:#fff
|
class db store;
|
||||||
style ExternalClients fill:#9E9E9E,stroke:#424242,color:#fff
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
- **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**:
|
- **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.
|
| Transaction | Description |
|
||||||
- **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.
|
| 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. |
|
||||||
- **CMS Content Management**: (Planned — module structure is in place but CMS-specific content modules are not yet implemented.)
|
| 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**:
|
- **Business Dictionary**:
|
||||||
- **Owner**: Highest-privilege role; can manage users, modules, and system availability.
|
|
||||||
- **Admin**: Can manage users and CMS content within their scope.
|
| Term | Meaning |
|
||||||
- **User**: Standard access; can use CMS features but cannot manage system settings.
|
|---|---|
|
||||||
- **Module**: An independently deployable feature unit that integrates into the CMS shell.
|
| **Module** | A self-contained functional unit implementing `IModule`, discovered from disk at startup. Determines what a deployed instance can do. |
|
||||||
- **Invitation**: A time-limited token sent to a new user allowing them to create their account.
|
| **Master** | An instance running the Master module, from which the availability of other instances is centrally managed. |
|
||||||
- **Availability Status**: Available | Maintenance | Unavailable — represents the operational state of the system.
|
| **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
|
## Component Level Business Descriptions
|
||||||
|
|
||||||
### SlpModularCms.Api
|
### SlpModularCms.Api (host / Client)
|
||||||
- **Purpose**: ASP.NET Core Web API host — the entry point for all HTTP requests.
|
- **Purpose**: The deployable application. Boots the module system and serves all three surfaces — public website, admin SPA and API — from one process.
|
||||||
- **Responsibilities**: Bootstraps the application, registers modules, configures middleware (auth, CORS, Swagger), exposes REST endpoints.
|
- **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
|
### SlpModularCms.Core
|
||||||
- **Purpose**: Shared domain core — entities, DbContext, interfaces, services, and migrations.
|
- **Purpose**: The shared foundation every module builds on.
|
||||||
- **Responsibilities**: Defines domain entities (ApplicationUser, ApplicationRole, Invitation, RefreshToken, GlobalAvailabilityState), persistence (EF Core + SQL Server), and shared service contracts.
|
- **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
|
### SlpModularCms.Modules.Identity
|
||||||
- **Purpose**: Authentication and user management module.
|
- **Purpose**: Exposes account and access management to clients.
|
||||||
- **Responsibilities**: Implements AuthController (login/refresh/revoke), SetupController (initial owner creation), UsersController (invite, complete-setup, validate-invitation).
|
- **Responsibilities**: Login/refresh/revoke and password change; first-Owner setup; invitations; user administration.
|
||||||
|
|
||||||
### SlpModularCms.Modules.Availability
|
### SlpModularCms.Modules.Availability
|
||||||
- **Purpose**: System availability / health status module.
|
- **Purpose**: Decides whether this instance serves requests, honouring both the local switch and the Master's verdict.
|
||||||
- **Responsibilities**: Implements AvailabilityController (get status, update status), caches status in-memory with circuit breaker, persists status changes to the database.
|
- **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
|
### SlpModularCms.Modules.Master
|
||||||
- **Purpose**: Unit tests for the Core layer.
|
- **Purpose**: Turns an instance into the central control point for other instances.
|
||||||
- **Responsibilities**: Tests for exception classes, invitation service logic, identity services.
|
- **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
|
### frontend (admin SPA)
|
||||||
- **Purpose**: Unit/integration tests for the Availability module.
|
- **Purpose**: The web UI through which Owners, Administrators and Users operate the CMS.
|
||||||
- **Responsibilities**: Tests for availability service logic and controller behavior.
|
- **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
|
## Test Coverage
|
||||||
- **Overall**: Fair — unit tests exist for Core and Availability modules
|
|
||||||
- **Unit Tests**: Present for Core.Tests and Modules.Availability.Tests
|
### Backend — all suites pass
|
||||||
- **Integration Tests**: Not observed in current structure
|
|
||||||
- **Frontend Tests**: None (example app has no test files)
|
| 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
|
## 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
|
- **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.
|
||||||
- **Documentation**: Good for core interfaces and entities (XML doc comments); controllers have minimal comments
|
- **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.
|
||||||
- **Naming**: Follows .NET conventions (PascalCase classes/methods, camelCase parameters)
|
- **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
|
## 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)
|
### Blocking for CI as it stands
|
||||||
- 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
|
1. **`pnpm run lint` fails: 5 errors, 1 warning.** Any workflow that gates on lint will go red on the current `master`:
|
||||||
- No OpenAPI/Swagger spec currently integrated (would help frontend integration)
|
- `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
|
## Patterns and Anti-patterns
|
||||||
|
|
||||||
### Good Patterns
|
### Good Patterns
|
||||||
- Module pattern provides clear separation of concerns between features
|
|
||||||
- JWT refresh token rotation is properly implemented
|
- **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.
|
||||||
- Authorization policies are well-defined (OwnerOnly, AdminOnly)
|
- **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.
|
||||||
- EF Core used consistently for persistence
|
- **`UpdateStatusResult { success, slaveContactSuccess }`** — honestly reports partial success instead of collapsing two different outcomes into one boolean.
|
||||||
- Service interfaces (IAuthService, IInvitationService, ISetupService) for testability
|
- **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
|
### Anti-patterns
|
||||||
- Direct implementation cast in `AvailabilityController` (should use extended interface instead)
|
|
||||||
- Example React app uses localStorage-based auth (acceptable for prototype, not production)
|
- Concrete-type cast in `AvailabilityController.UpdateStatus` (item 14).
|
||||||
- Example React app `auth-context` hardcodes mock users (must be replaced with real API calls)
|
- 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
|
## Build System
|
||||||
- **Type**: .NET SDK (MSBuild / dotnet CLI)
|
|
||||||
- **Configuration**: `SlpModularCms.sln` — solution file referencing all projects
|
- **Type**: .NET SDK (MSBuild / `dotnet` CLI) for the backend; pnpm + Vite for the admin SPA.
|
||||||
- **Target Framework**: `net10.0`
|
- **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
|
## Project Structure
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Root["SlpModularCms/"]
|
root["SlpModularCms (repo root)"]
|
||||||
Src["src/"]
|
sln["SlpModularCms.sln"]
|
||||||
Api["SlpModularCms.Api\n(API host)"]
|
src["src/"]
|
||||||
ApiExt["Extensions/\nServiceCollectionExtensions.cs"]
|
fe["frontend/ (admin SPA)"]
|
||||||
ApiInfra["Infrastructure/\nGlobal exception handler"]
|
docs["aidlc-docs/"]
|
||||||
ApiProg["Program.cs\nApp startup + module loading"]
|
|
||||||
|
|
||||||
Core["SlpModularCms.Core\n(Shared core)"]
|
api["SlpModularCms.Api<br/>Client / host"]
|
||||||
CoreAvail["Availability/\nAvailabilityOptions.cs\nAvailabilityStatus.cs"]
|
slave["SlpModularCms.Api.Slave<br/>Client / host"]
|
||||||
CoreData["Data/\nApplicationDbContext.cs"]
|
core["SlpModularCms.Core<br/>shared library"]
|
||||||
CoreIdentity["Identity/\nEntities, Models, Services\nAuthorization/"]
|
mid["Modules.Identity"]
|
||||||
CoreMigrations["Migrations/\nEF Core migrations"]
|
mav["Modules.Availability"]
|
||||||
CoreModules["Modules/\nIModule.cs, ModuleInfo.cs"]
|
mma["Modules.Master"]
|
||||||
|
tests["4 test projects<br/>Core, Identity, Availability, Master"]
|
||||||
|
|
||||||
ModIdentity["SlpModularCms.Modules.Identity\n(Identity module)"]
|
root --> sln
|
||||||
ModIdentityCtrl["Controllers/\nAuthController\nSetupController\nUsersController"]
|
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)"]
|
classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
ModAvailCtrl["Controllers/\nAvailabilityController"]
|
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
ModAvailSvc["Services/\nPersistentAvailabilityService"]
|
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||||
Tests1["SlpModularCms.Core.Tests"]
|
classDef meta fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
Tests2["SlpModularCms.Modules.Availability.Tests"]
|
class api,slave,fe client;
|
||||||
Docs["aidlc-docs/\nAI-DLC workflow documentation"]
|
class core corelayer;
|
||||||
|
class mid,mav,mma module;
|
||||||
Root --> Src
|
class tests test;
|
||||||
Root --> Docs
|
class root,sln,src,docs meta;
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Key Classes/Modules
|
||||||
|
|
||||||
### Core Domain Entities
|
```mermaid
|
||||||
- `ApplicationUser` — extends `IdentityUser<Guid>` with `IsActive`, `CreatedAt`, `Naam`
|
classDiagram
|
||||||
- `ApplicationRole` — extends `IdentityRole<Guid>`
|
class IModule {
|
||||||
- `RefreshToken` — linked to user; has `Token`, `ExpiryDate`, `IsRevoked`, `IsActive`
|
+string Name
|
||||||
- `Invitation` — linked to user (invitee); has `Token`, `ExpiryDate`, `IsUsed`, `Role`
|
+string Version
|
||||||
- `GlobalAvailabilityState` — singleton-ish entity storing `Status`, `Message`, `LastUpdatedAt`, `UpdatedBy`
|
+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
|
IModule <|.. IdentityModule
|
||||||
- `IAuthService` / `AuthService` — `AuthenticateAsync`, `RefreshTokenAsync`, `RevokeTokenAsync`
|
IModule <|.. AvailabilityModule
|
||||||
- `IInvitationService` / `InvitationService` — `CreateInvitationAsync`, `CompleteInvitationAsync`, `ValidateInvitationAsync`
|
IModule <|.. MasterModule
|
||||||
- `ISetupService` / `SetupService` — `IsSystemInitializedAsync`, `CreateInitialOwnerAsync`
|
ModuleOrchestrator --> IModule
|
||||||
- `IAvailabilityService` / `PersistentAvailabilityService` — `IsAvailableAsync`, `UpdateStatusAsync`
|
IAvailabilityService <|.. PersistentAvailabilityService
|
||||||
|
IMasterAvailabilityService <|.. MasterAvailabilityService
|
||||||
|
```
|
||||||
|
|
||||||
### Module System
|
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.
|
||||||
- `IModule` — interface: `RegisterServices(IServiceCollection)`, `UseModule(IApplicationBuilder)`
|
|
||||||
- Modules discovered at startup and invoked in sequence
|
### 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
|
## 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
|
### Module / plugin pattern
|
||||||
- **Location**: `ApplicationDbContext` used directly in services
|
- **Location**: `Core/Modules/IModule.cs`, `Core/Hosting/ModuleOrchestrator.cs`, each `*Module.cs`.
|
||||||
- **Purpose**: Centralized persistence with Entity Framework
|
- **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
|
### Repository pattern
|
||||||
- **Location**: `AuthService.cs`, `AuthController.cs`
|
- **Location**: `Modules.Master/Repositories/`, `Modules.Availability/Repositories/`.
|
||||||
- **Purpose**: Stateless auth with token refresh capability
|
- **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
|
## Critical Dependencies
|
||||||
### ASP.NET Core Identity
|
|
||||||
- **Version**: .NET 10 built-in
|
|
||||||
- **Usage**: User/Role management, password hashing
|
|
||||||
- **Purpose**: Provides authentication primitives
|
|
||||||
|
|
||||||
### Entity Framework Core
|
### Microsoft.EntityFrameworkCore.SqlServer — 10.0.9
|
||||||
- **Version**: .NET 10 built-in
|
- **Usage**: All three `DbContext` types, one shared connection string.
|
||||||
- **Usage**: Data persistence with SQL Server provider
|
- **Purpose**: Persistence. Module contexts self-migrate; the Core context does not.
|
||||||
- **Purpose**: ORM for all domain entities
|
|
||||||
|
### 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
|
## 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
|
- `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).
|
||||||
- `SlpModularCms.Core` — Core domain: entities, DbContext, services, module interface, migrations
|
|
||||||
|
### 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 Protection–encrypted 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
|
## 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)
|
- `src/SlpModularCms.Core.Tests` — Unit (7 files): Exceptions, Hosting, Identity.
|
||||||
- `SlpModularCms.Frontend` — React SPA; admin panel for CMS management
|
- `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 Count
|
||||||
- **Total Packages**: 6 (5 existing .NET + 1 new frontend)
|
|
||||||
- **Application**: 3 (Api, Modules.Identity, Modules.Availability)
|
- **Total .NET projects in the solution**: 10
|
||||||
- **Shared**: 1 (Core)
|
- **Clients (deployable)**: 2 — `Api`, `Api.Slave`
|
||||||
- **Test**: 2 (Core.Tests, Modules.Availability.Tests)
|
- **Application**: 4 — `Core`, `Modules.Identity`, `Modules.Availability`, `Modules.Master`
|
||||||
- **Frontend**: 1 (to be built)
|
- **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
|
## Internal Dependencies
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
Api["SlpModularCms.Api"]
|
api["SlpModularCms.Api<br/>Client"]
|
||||||
Core["SlpModularCms.Core"]
|
slave["SlpModularCms.Api.Slave<br/>Client"]
|
||||||
ModIdentity["SlpModularCms.Modules.Identity"]
|
core["SlpModularCms.Core"]
|
||||||
ModAvail["SlpModularCms.Modules.Availability"]
|
mid["Modules.Identity"]
|
||||||
CoreTests["SlpModularCms.Core.Tests"]
|
mav["Modules.Availability"]
|
||||||
AvailTests["SlpModularCms.Modules.Availability.Tests"]
|
mma["Modules.Master"]
|
||||||
Frontend["SlpModularCms.Frontend\n(to be built)"]
|
tcore["Core.Tests"]
|
||||||
|
tid["Modules.Identity.Tests"]
|
||||||
|
tav["Modules.Availability.Tests"]
|
||||||
|
tma["Modules.Master.Tests"]
|
||||||
|
fe["frontend<br/>admin SPA"]
|
||||||
|
|
||||||
Api -->|compile| Core
|
api --> core
|
||||||
Api -->|compile| ModIdentity
|
api --> mid
|
||||||
Api -->|compile| ModAvail
|
api --> mav
|
||||||
ModIdentity -->|compile| Core
|
api --> mma
|
||||||
ModAvail -->|compile| Core
|
slave --> core
|
||||||
CoreTests -->|test| Core
|
slave --> mid
|
||||||
AvailTests -->|test| ModAvail
|
slave --> mav
|
||||||
AvailTests -->|test| Core
|
mid --> core
|
||||||
Frontend -->|runtime REST| Api
|
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
|
classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
style Core fill:#FFC107,stroke:#F57F17,color:#000
|
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
style ModIdentity fill:#4CAF50,stroke:#2E7D32,color:#fff
|
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
style ModAvail fill:#4CAF50,stroke:#2E7D32,color:#fff
|
classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
|
||||||
style CoreTests fill:#9E9E9E,stroke:#424242,color:#fff
|
classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
style AvailTests fill:#9E9E9E,stroke:#424242,color:#fff
|
class api,slave client;
|
||||||
style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff
|
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
|
### Dependency Details
|
||||||
|
|
||||||
#### SlpModularCms.Api depends on SlpModularCms.Core
|
#### `SlpModularCms.Api` → `SlpModularCms.Core`
|
||||||
- **Type**: Compile
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
#### `frontend` → `SlpModularCms.Api`
|
||||||
- **Version**: .NET 10 built-in
|
- **Type**: Runtime (HTTP/REST)
|
||||||
- **Purpose**: User and role management, password hashing
|
- **Reason**: All data comes from `/api/v1/**` at `VITE_API_BASE_URL`, with credentials so the refresh cookie travels.
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### Microsoft.EntityFrameworkCore + SqlServer provider
|
#### `SlpModularCms.Api` → `frontend` (build-time, reverse direction)
|
||||||
- **Version**: .NET 10 built-in
|
- **Type**: Build
|
||||||
- **Purpose**: Data persistence
|
- **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.**
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### Microsoft.AspNetCore.Authentication.JwtBearer
|
### Cross-instance runtime dependencies
|
||||||
- **Version**: .NET 10 built-in
|
|
||||||
- **Purpose**: JWT authentication middleware
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### Microsoft.IdentityModel.Tokens
|
Not project references, but real coupling between deployed instances:
|
||||||
- **Version**: .NET 10 built-in
|
|
||||||
- **Purpose**: JWT token creation and validation
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
## 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
|
## External Dependencies
|
||||||
- **Version**: 18.3.1
|
|
||||||
- **Purpose**: Core UI framework
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### react-router
|
### Backend — `SlpModularCms.Core`
|
||||||
- **Version**: 7.13.0
|
|
||||||
- **Purpose**: Client-side routing
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### @radix-ui/* (multiple packages)
|
| Package | Version | Purpose | License |
|
||||||
- **Version**: Various (1.x–2.x)
|
|---|---|---|---|
|
||||||
- **Purpose**: shadcn/ui component primitives
|
| `Microsoft.EntityFrameworkCore.SqlServer` | 10.0.9 | SQL Server persistence | MIT |
|
||||||
- **License**: 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
|
### Backend — `SlpModularCms.Api`
|
||||||
- **Version**: 4.1.12
|
|
||||||
- **Purpose**: Utility-first CSS framework
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### lucide-react
|
| Package | Version | Purpose | License |
|
||||||
- **Version**: 0.487.0
|
|---|---|---|---|
|
||||||
- **Purpose**: Icon library
|
| `Asp.Versioning.Mvc` | 10.0.0 | API versioning | MIT |
|
||||||
- **License**: ISC
|
| `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
|
`SlpModularCms.Api.Slave` carries `Microsoft.EntityFrameworkCore.Design` and `Scalar.AspNetCore` at the same versions.
|
||||||
- **Version**: 2.15.2
|
|
||||||
- **Purpose**: Charts and data visualization
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### react-hook-form
|
### Backend — modules
|
||||||
- **Version**: 7.55.0
|
|
||||||
- **Purpose**: Form state management
|
|
||||||
- **License**: MIT
|
|
||||||
|
|
||||||
### sonner
|
| Project | Package | Version | Purpose | License |
|
||||||
- **Version**: 2.0.3
|
|---|---|---|---|---|
|
||||||
- **Purpose**: Toast notifications
|
| `Modules.Master` | `Microsoft.Extensions.Http.Resilience` | 9.6.0 | Retry/timeout for master→slave calls (pulls in Polly) | MIT |
|
||||||
- **License**: 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.x–2.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
|
**Analysis Date**: 2026-07-27T00:00:00Z
|
||||||
**Analyzer**: AI-DLC (Junie)
|
**Analyzer**: AI-DLC (Claude Code)
|
||||||
**Workspace**: K:\Development\Projects\SlpModularCms
|
**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
|
## Artifacts Generated
|
||||||
- [x] business-overview.md
|
- [x] business-overview.md
|
||||||
@@ -14,3 +24,6 @@
|
|||||||
- [x] technology-stack.md
|
- [x] technology-stack.md
|
||||||
- [x] dependencies.md
|
- [x] dependencies.md
|
||||||
- [x] code-quality-assessment.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
|
## Backend
|
||||||
|
|
||||||
### Programming Languages
|
### Programming Languages
|
||||||
- C# 14.0 — All backend packages
|
- C# (latest for `net10.0`) — all backend projects. `Nullable` and `ImplicitUsings` enabled everywhere.
|
||||||
|
|
||||||
### Frameworks
|
### Frameworks
|
||||||
- ASP.NET Core 10.0 — Web API framework
|
- .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 — User/role management, password hashing
|
- ASP.NET Core Identity (`Microsoft.AspNetCore.Identity.EntityFrameworkCore` 10.0.9) — users, roles, password hashing and policy.
|
||||||
- Entity Framework Core 10.0 — ORM for SQL Server persistence
|
- 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
|
### Infrastructure
|
||||||
- SQL Server — Primary database
|
- SQL Server — one database per instance. Local development via a container (`mcr.microsoft.com/mssql/server:2022-latest`) or LocalDB.
|
||||||
- JWT Bearer Authentication — Stateless auth with refresh tokens
|
- 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
|
### Build Tools
|
||||||
- .NET 10 SDK / dotnet CLI — Build, test, publish
|
- .NET SDK 10 / `dotnet` CLI — build, test, publish.
|
||||||
- MSBuild — Underlying build engine
|
- 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
|
### Testing Tools
|
||||||
- xUnit (inferred from project conventions) — Unit testing framework
|
- xUnit 2.9.3 with `xunit.runner.visualstudio` 3.1.4 and `Microsoft.NET.Test.Sdk` 17.14.1.
|
||||||
- Moq or similar (inferred) — Mocking in unit tests
|
- 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 (admin SPA, `frontend/`)
|
||||||
|
|
||||||
## Frontend (Example App — ZIP file basis)
|
|
||||||
|
|
||||||
### Programming Languages
|
### Programming Languages
|
||||||
- TypeScript — All frontend code
|
- TypeScript `~6.0.2` — all frontend code.
|
||||||
|
|
||||||
### Frameworks
|
### Frameworks and Libraries
|
||||||
- React 18.3.1 — UI framework
|
- React 19.2 + React DOM 19.2.
|
||||||
- TanStack Router — Client-side routing (replaces React Router v7 from example app; chosen for full TypeScript safety and modern routing features)
|
- Vite 8.0 with `@vitejs/plugin-react` 6 — build and dev server. `base: '/admin/'` on build only.
|
||||||
- Tailwind CSS v4 (4.1.12) — Utility-first CSS framework
|
- TanStack Router 1.170 — routing, `basepath: import.meta.env.BASE_URL` so it follows the `/admin/` base.
|
||||||
- shadcn/ui (via Radix UI) — Accessible component primitives
|
- TanStack React Query 5.101 — server state.
|
||||||
|
- Tailwind CSS 4.3 via `@tailwindcss/vite` — styling.
|
||||||
### UI Component Libraries
|
- Radix UI primitives (dialog, dropdown-menu, label, select, slot) with shadcn-style wrappers; `class-variance-authority`, `clsx`, `tailwind-merge`.
|
||||||
- Radix UI — Headless component primitives (accordion, dialog, dropdown, etc.)
|
- `lucide-react` 1.21 — icons. `sonner` 2.0 — toasts.
|
||||||
- lucide-react (0.487.0) — SVG icon library
|
- `react-hook-form` 7.79 with `@hookform/resolvers` 5.4 and `zod` 4.4 — forms and validation. Zod also validates app config.
|
||||||
- recharts (2.15.2) — Charts and data visualization
|
- `i18next` 26 / `react-i18next` 17 / `i18next-browser-languagedetector` 8 — NL/EN.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Build Tools
|
### Build Tools
|
||||||
- Vite 6.3.5 — Build tool and dev server
|
- pnpm — package manager. Observed locally: pnpm 10.33.2, Node v22.15.1. (The README states Node 20+ and pnpm 9+ as the requirement.)
|
||||||
- pnpm — Package manager (pnpm-workspace.yaml present)
|
- `tsc -b` runs before `vite build`, so type errors fail the build.
|
||||||
- PostCSS — CSS processing
|
|
||||||
|
|
||||||
### Theme
|
### Testing Tools
|
||||||
- Primary color: `#ac0000` (deep red)
|
- Vitest 4.1 with `@vitest/coverage-v8` and jsdom 29.
|
||||||
- Mode: Light + dark via CSS custom properties
|
- 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.
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
| Master CMS Module (master-cms-module) | ✅ Complete | unknown | Modules, Availability | 2026-06-26 |
|
| Master CMS Module (master-cms-module) | ✅ Complete | unknown | Modules, Availability | 2026-06-26 |
|
||||||
| Tech Debt Backlog (tech-debt-backlog) | 🔵 Inception | unknown | Modules.Master, Frontend | 2026-07-01 |
|
| Tech Debt Backlog (tech-debt-backlog) | 🔵 Inception | unknown | Modules.Master, Frontend | 2026-07-01 |
|
||||||
| Local Dev Master/Slave Setup (local-dev-master-slave-setup) | ✅ Complete | unknown | Modules.Master, Api, Frontend | 2026-07-02 |
|
| Local Dev Master/Slave Setup (local-dev-master-slave-setup) | ✅ Complete | unknown | Modules.Master, Api, Frontend | 2026-07-02 |
|
||||||
|
| Gitea Deployment Workflow (gitea-deployment-workflow) | 🟢 Construction | feature/gitea-deployment-workflow | CI/CD, Api (hosting/config), Core, Modules, Frontend (build), Docs | 2026-07-27 |
|
||||||
|
|||||||
@@ -1,16 +1,72 @@
|
|||||||
# Feature Resolution
|
# 🔍 Feature Resolution
|
||||||
|
|
||||||
I found an existing AI-DLC workspace with the following features:
|
Ik vond een bestaande AI-DLC workspace met de volgende features:
|
||||||
|
|
||||||
| # | Feature | Status | Branch |
|
| # | Feature | Status | Branch |
|
||||||
|---|---------|--------|--------|
|
|---|---------|--------|--------|
|
||||||
| 1 | SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown |
|
| 1 | SlpModularCms.Api Implementation (`slp-modular-cms-api`) | ✅ Complete | unknown |
|
||||||
| 2 | CMS Frontend (cms-frontend) | ✅ Complete | unknown |
|
| 2 | CMS Frontend (`cms-frontend`) | ✅ Complete | unknown |
|
||||||
|
| 3 | Master CMS Module (`master-cms-module`) | ✅ Complete | unknown |
|
||||||
|
| 4 | Tech Debt Backlog (`tech-debt-backlog`) | 🔵 Inception | unknown |
|
||||||
|
| 5 | Local Dev Master/Slave Setup (`local-dev-master-slave-setup`) | ✅ Complete | unknown |
|
||||||
|
|
||||||
**What would you like to do?**
|
Jouw verzoek (Gitea deployment-workflow voor de CMS, met omgevingen lokaal/test/productie, UptimeRobot + Umami + Sentry, en instructies over waar de publieke website-frontend komt) matcht geen van de bestaande features — dit lijkt nieuw werk.
|
||||||
|
|
||||||
A) Continue working on an existing feature (neither of the above match this request)
|
---
|
||||||
B) Start a NEW feature for this request — Master CMS Module (recommended)
|
|
||||||
C) Other (please describe)
|
|
||||||
|
|
||||||
[Answer]: B
|
## Question 1
|
||||||
|
Wil je hiervoor een nieuwe feature starten of aansluiten op een bestaande?
|
||||||
|
|
||||||
|
A) Start een NIEUWE feature: `gitea-deployment-workflow`
|
||||||
|
B) Voortzetten op bestaande feature `tech-debt-backlog` (nog in Inception)
|
||||||
|
C) Voortzetten op een andere bestaande feature (geef het nummer)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A, maak ook een nieuwe feature-branch aan voor deze feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question 2
|
||||||
|
Taalvoorkeur voor deze feature.
|
||||||
|
|
||||||
|
Alle documentatie-artefacten (requirements, designs, plannen, code-commentaar) worden standaard in **Engels** geschreven. Vragen, prompts en mijn antwoorden zijn in jouw taal (Nederlands).
|
||||||
|
|
||||||
|
Wil je dit wijzigen?
|
||||||
|
|
||||||
|
A) Engels voor documentatie, Nederlands voor de conversatie (standaard)
|
||||||
|
B) Engels voor alles (documentatie en conversatie)
|
||||||
|
C) Nederlands voor alles (documentatie en conversatie)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question 3
|
||||||
|
De reverse-engineering-artefacten in `aidlc-docs/_shared/reverse-engineering/` zijn van **2026-06-16**, maar de codebase is daarna significant gewijzigd — en juist op punten die voor deployment relevant zijn:
|
||||||
|
|
||||||
|
- Master CMS Module toegevoegd (eigen `DbContext`/migraties, Data Protection key ring)
|
||||||
|
- `SlpModularCms.Api.Slave` project toegevoegd
|
||||||
|
- Solution gereorganiseerd in Solution Folders (Application / Tests / Clients)
|
||||||
|
- **Single-host serving** (commit `3885703`): één .NET-proces serveert `/` (publieke website), `/admin` (admin SPA) en `/api/v1` — dit is precies het deployment-model waar deze feature over gaat
|
||||||
|
|
||||||
|
Hoe wil je hiermee omgaan?
|
||||||
|
|
||||||
|
A) Doelgerichte refresh — werk alleen `architecture.md`, `code-structure.md` en `technology-stack.md` bij op de deployment-relevante punten (sneller, voldoende voor deze feature)
|
||||||
|
B) Volledige reverse engineering opnieuw uitvoeren — alle 8 artefacten van de hele codebase verversen (grondig, kost meer tijd)
|
||||||
|
C) Overslaan — gebruik de bestaande artefacten plus mijn eigen actuele analyse van de repo, en laat `_shared/` ongewijzigd
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question 4
|
||||||
|
Ter verduidelijking van de scope van de publieke website. Uit de README begrijp ik dat de publieke website van de klant **niet** in deze repo zit en los in `wwwroot/` wordt geplaatst. Wat moet deze feature daarover opleveren?
|
||||||
|
|
||||||
|
A) Alleen documentatie/instructies — waar de website-build terechtkomt, hoe die naast `wwwroot/admin/` bestaat, en wat een website-workspace moet aanleveren; de website-workflow zelf blijft buiten scope
|
||||||
|
B) Documentatie + een reusable/callable Gitea-workflow in déze repo die een website-workspace kan aanroepen om zijn build te deployen (contract vastleggen, maar de website bouwt hij niet zelf)
|
||||||
|
C) Documentatie + een placeholder/voorbeeld-website in `wwwroot/` zodat een verse deploy niet leeg is
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
# Application Design — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Feature**: `gitea-deployment-workflow`
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
**Consolidates**: `components.md`, `component-methods.md`, `services.md`, `component-dependency.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Summary
|
||||||
|
|
||||||
|
This feature adds **cross-cutting infrastructure** to an existing modular monolith, plus the CI/CD pipeline that deploys it. It introduces no new business capability and no new domain entity beyond a Data Protection keys table.
|
||||||
|
|
||||||
|
The design is shaped by four decisions taken during this stage:
|
||||||
|
|
||||||
|
| Decision | Choice | Consequence |
|
||||||
|
|---|---|---|
|
||||||
|
| **Q1 = A** — placement | All new cross-cutting concerns live in `SlpModularCms.Core` as extension methods | Both hosts get identical behaviour; nothing is implemented twice |
|
||||||
|
| **Q2 = A** — Slave scope | The Slave host receives everything except static mounts | The Slave is a **reference instance** showing what a customer-facing API looks like, not a stripped-down dev tool. Its behaviour must match production |
|
||||||
|
| **Q9 = A** — composition | Each host keeps its own `Program.cs` | What remains duplicated is a readable list of calls, not logic |
|
||||||
|
| **Q8 = A** — failure mode | Fail fast on migration failure | Makes the liveness-only health check meaningful: a process that cannot migrate never starts, so `/health` goes silent and monitoring goes red |
|
||||||
|
|
||||||
|
**14 code components** (9 new, 5 modified) and **2 workflow components**, across `Core`, `Api`, `Modules.Availability`, `frontend` and `.gitea/workflows/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Components
|
||||||
|
|
||||||
|
See `components.md` for full definitions. Summary:
|
||||||
|
|
||||||
|
### New in `SlpModularCms.Core`
|
||||||
|
- **C-01 `SecurityHeadersMiddleware`** — applies headers that would normally come from nginx or IIS, which NFR-01 forbids relying on
|
||||||
|
- **C-02 `SecurityHeadersOptions`** — binds the `SecurityHeaders` section
|
||||||
|
- **C-03 `CspPolicyBuilder`** — composes CSP strings from code-defined policies plus configured origins
|
||||||
|
- **C-04 Health-check registration** — `GET /health`, liveness only
|
||||||
|
- **C-05 `CmsDataProtection`** — database-backed key ring
|
||||||
|
- **C-07 Startup migration runner** — Core migrations, fail fast
|
||||||
|
- **C-08 `CmsLogging`** — structured logging, independent of Sentry
|
||||||
|
- **C-09 `CmsSentry`** — optional; absent DSN is a supported state
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
- **C-06 `ApplicationDbContext`** — implements `IDataProtectionKeyContext`; one new Core migration
|
||||||
|
- **C-13 `AvailabilityMiddleware`** — `/health` bypass, plus the FR-24 token-validation fix
|
||||||
|
- **C-14/C-15 frontend** — same-origin config, Sentry, Umami
|
||||||
|
- **C-16 host composition** — both `Program.cs` files
|
||||||
|
|
||||||
|
### Host-specific and workflow
|
||||||
|
- **C-10 Static mounts** — `Api` only: `wwwroot/web/` at `/`, `wwwroot/admin/` at `/admin`
|
||||||
|
- **C-11 `deploy-scp.yaml`**, **C-12 `continuous_integration.yaml`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Interfaces
|
||||||
|
|
||||||
|
See `component-methods.md` for full signatures. The contracts that carry design weight:
|
||||||
|
|
||||||
|
**`AddCmsHealthChecks()` takes no options.** Deliberate — "just add a database check" then becomes a visible code change rather than configuration drift, keeping D-21 (liveness only) enforced by shape rather than by discipline.
|
||||||
|
|
||||||
|
**`MigrateCoreDatabase()` has no try/catch.** Exceptions propagate by design (Q8 = A).
|
||||||
|
|
||||||
|
**`AddCmsSentry()` treats an absent DSN as normal.** Local development and any Sentry-less deployment run unchanged.
|
||||||
|
|
||||||
|
**`SecurityHeadersOptions` configures assignment, not definition.** Policy *content* is in code; path *assignment* and environment-specific *origins* are configuration (FU2 = A). A misconfiguration can misroute a path but cannot invent a broken policy.
|
||||||
|
|
||||||
|
**Both deploy workflows share one input interface** (Q11 = B), so switching transport changes only a `uses:` line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Orchestration
|
||||||
|
|
||||||
|
See `services.md` for the full pipeline. The two orderings that matter most:
|
||||||
|
|
||||||
|
**Security headers precede static files.** Static files short-circuit the pipeline; anything registered after them never reaches the public website — the very surface the CSP is for. Headers are applied at response start via `OnStarting`, because the content type is unknown earlier and per-header scoping (FU1 = A) depends on it.
|
||||||
|
|
||||||
|
**`/health` is an endpoint, so it runs after the availability gate.** That is exactly why `/health` must be on the bypass list (D-22) — otherwise a deliberately disabled instance would report itself as unhealthy, re-creating the conflation this feature exists to avoid.
|
||||||
|
|
||||||
|
**Startup order**: logging and Sentry first (so later failures are captured) → module discovery → services including Data Protection → build → Core migration (fail fast) → module migrations → serve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Two Conflicts Found During Design
|
||||||
|
|
||||||
|
Both were discovered by tracing the composition order, and both would have produced code that looks correct while doing nothing useful.
|
||||||
|
|
||||||
|
### 5.1 Duplicate `AddDataProtection()` would silently defeat FR-12
|
||||||
|
|
||||||
|
`AvailabilityModule.RegisterServices` and `MasterModule.RegisterServices` each call `services.AddDataProtection()` today. Module registration runs **after** the host's registration, so the modules' bare calls would override the persistent key store configured by `AddCmsDataProtection()`.
|
||||||
|
|
||||||
|
The result: FR-12 appears implemented, tests that check registration pass, and the key ring is still ephemeral — so the first atomic release switch silently breaks master↔slave trust in a way that presents as a network fault. Exactly the failure FR-12 exists to prevent.
|
||||||
|
|
||||||
|
**Resolution**: remove `AddDataProtection()` from both modules; the host configures Data Protection once. Assigned to Unit 2, with a test asserting the persistent store survives module registration.
|
||||||
|
|
||||||
|
**Second-order requirement**: the Data Protection **application discriminator must be set explicitly**. By default it derives from the content root path, which changes on every atomic release-directory switch (FR-06) — which would defeat FR-12 by a different route.
|
||||||
|
|
||||||
|
### 5.2 `AvailabilityMiddleware` runs before authentication
|
||||||
|
|
||||||
|
FR-24 requires the admin bypass to stop trusting an unvalidated token. But `AvailabilityMiddleware` is installed by `orchestrator.UseModules(app)` at step 8, while `UseAuthentication()` runs at step 9 — so `HttpContext.User` is not yet populated when the bypass is evaluated.
|
||||||
|
|
||||||
|
Two options, decided in Functional Design for Unit 2:
|
||||||
|
|
||||||
|
| Option | Trade-off |
|
||||||
|
|---|---|
|
||||||
|
| **(a)** Validate the token inside the middleware with the same `TokenValidationParameters` as the bearer scheme | Contained, but duplicates validation parameters — which must then be shared from one source rather than copied |
|
||||||
|
| **(b)** Move `UseAuthentication()` before the module middleware | Smaller code change, but alters the pipeline for every module including future ones — a wider blast radius than this feature warrants |
|
||||||
|
|
||||||
|
Neither is obviously correct, which is why it is recorded rather than decided here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dependencies
|
||||||
|
|
||||||
|
See `component-dependency.md` for the matrix and data flows. The coupling that deserves attention:
|
||||||
|
|
||||||
|
**The `wwwroot/web/` filesystem convention is the feature's weakest link.** It is enforced by convention, not by the type system, and getting it wrong destroys a customer's website (NFR-02). Two design obligations follow:
|
||||||
|
1. `C-10` must tolerate a **missing `wwwroot/web/` at startup** — a fresh deployment has none until a website workspace deploys into it, and the CMS must still start and serve `/admin` and `/api/v1`
|
||||||
|
2. Deployment must link the **persistent** `wwwroot/web/` into each new release directory (ASM-01)
|
||||||
|
|
||||||
|
**`Core` reaches the Slave.** Every change lands in a host with no test project, verified at Build and Test by starting it.
|
||||||
|
|
||||||
|
**Build-time frontend configuration** forces two artifacts (D-15), and the publish target makes Node and pnpm prerequisites of `dotnet publish`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Covered by |
|
||||||
|
|---|---|
|
||||||
|
| FR-01, FR-03, FR-04, FR-05 | C-12 |
|
||||||
|
| FR-02 | C-11, S-06 |
|
||||||
|
| FR-06, FR-20 | S-05 |
|
||||||
|
| FR-07, FR-08 | C-10, S-01 |
|
||||||
|
| FR-09 | Unit 7 documentation (Infrastructure Design input) |
|
||||||
|
| FR-10 | C-04, C-13, S-01 |
|
||||||
|
| FR-11 | C-07, S-03 |
|
||||||
|
| FR-12 | C-05, C-06 — **and § 5.1** |
|
||||||
|
| FR-13 | C-14 |
|
||||||
|
| FR-14 | C-09 |
|
||||||
|
| FR-15, FR-16 | C-15 |
|
||||||
|
| FR-17 | Operations phase |
|
||||||
|
| FR-18 | C-01, C-02, C-03, S-02 |
|
||||||
|
| FR-19 | C-09 + NFR Design, Unit 4 |
|
||||||
|
| FR-21, FR-22 | Unit 1 — no design needed |
|
||||||
|
| FR-23 | Operations phase |
|
||||||
|
| FR-24 | C-13 — **and § 5.2** |
|
||||||
|
|
||||||
|
All 24 functional requirements are accounted for: 18 by a designed component, 3 by the Operations phase, 2 needing no design, and 1 (FR-09) depending on Infrastructure Design output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Compliance (Security Baseline extension — enabled, blocking)
|
||||||
|
|
||||||
|
Assessed against the design.
|
||||||
|
|
||||||
|
| Rule | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SECURITY-01 | Addressed | TLS enforced in connection strings; HSTS on all responses (FU1 = A) |
|
||||||
|
| SECURITY-02 | N/A | No load balancer, API gateway or CDN in this architecture |
|
||||||
|
| SECURITY-03 | Addressed | C-08 structured logging with correlation ID; no secrets or PII. Mechanism decided in NFR Design (OPEN-01) |
|
||||||
|
| SECURITY-04 | Addressed | C-01/C-02/C-03, with per-header scoping so `nosniff` covers assets — the specific gap caught in follow-up round 2 |
|
||||||
|
| SECURITY-05 | Unchanged | `/health` is the only new endpoint and accepts no input |
|
||||||
|
| SECURITY-06 | Addressed | Deploy credentials scoped to the target; Gitea secrets repository-scoped |
|
||||||
|
| SECURITY-07 | Partially N/A | No cloud networking; applicable parts are documented host setup |
|
||||||
|
| SECURITY-08 | **Improved** | C-13 closes the forged-token bypass (FR-24) — a pre-existing finding this feature now fixes rather than inherits |
|
||||||
|
| SECURITY-09 | Addressed | Directory browsing stays disabled; Scalar remains Development-only; production errors stay generic; no default credentials |
|
||||||
|
| SECURITY-10 | Addressed | Blocking vulnerability gate; pinned tool versions; `--frozen-lockfile`. DEV-02 records the missing `packages.lock.json` and SBOM |
|
||||||
|
| SECURITY-11 | Addressed | Rate limiting pre-exists; security logic stays in `Core/Identity`; the misuse cases explicitly designed against are website destruction (NFR-02) and silent key-ring loss (§ 5.1) |
|
||||||
|
| SECURITY-12 | Unchanged | Pre-existing; DEV-03 records absent MFA and breached-password checking |
|
||||||
|
| SECURITY-13 | Addressed | SRI for external scripts where supported; CSP constrains them; pipeline definitions are version-controlled and reviewable |
|
||||||
|
| SECURITY-14 | Addressed with DEV-01 | Alerting via C-09; retention deviation accepted |
|
||||||
|
| SECURITY-15 | Unchanged | Global exception handler pre-exists. **New fail-closed decision**: an unknown CSP policy name fails at startup rather than degrading silently. The master gate's deliberate fail-open remains an intentional, business-driven exception |
|
||||||
|
|
||||||
|
**No blocking security findings.** SECURITY-08 improves from "pre-existing, unchanged" to "improved" because Q12 = A folded the fix into this feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Carried Forward to Construction
|
||||||
|
|
||||||
|
| Item | Resolved at |
|
||||||
|
|---|---|
|
||||||
|
| **§ 5.1** — remove duplicate `AddDataProtection()`; set an explicit application discriminator | Functional Design + Code Generation, Unit 2 |
|
||||||
|
| **§ 5.2** — FR-24 implementation approach (validate in middleware versus move authentication) | Functional Design, Unit 2 |
|
||||||
|
| OPEN-01 — correlation-ID mechanism | NFR Design, Unit 4 |
|
||||||
|
| OPEN-03 — patched versions for the two vulnerable packages | Code Generation, Unit 1 |
|
||||||
|
| ASM-01 — `wwwroot/web/` outside the swapped release directory | Infrastructure Design, Unit 6 |
|
||||||
|
| Definition of an alertable security event | NFR Design, Unit 4 |
|
||||||
|
| `C-10` tolerating a missing `wwwroot/web/` at startup | Functional Design, Unit 2 |
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
# Component Dependencies
|
||||||
|
|
||||||
|
Dependency matrix, communication patterns and data flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph core["SlpModularCms.Core"]
|
||||||
|
sechdr["C-01 SecurityHeadersMiddleware"]
|
||||||
|
secopt["C-02 SecurityHeadersOptions"]
|
||||||
|
csp["C-03 CspPolicyBuilder"]
|
||||||
|
health["C-04 Health checks"]
|
||||||
|
dp["C-05 CmsDataProtection"]
|
||||||
|
appdb["C-06 ApplicationDbContext<br/>plus keys table"]
|
||||||
|
migrate["C-07 Migration runner"]
|
||||||
|
logging["C-08 CmsLogging"]
|
||||||
|
sentry["C-09 CmsSentry"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph apihost["SlpModularCms.Api"]
|
||||||
|
statics["C-10 Static mounts"]
|
||||||
|
prog["C-16 Host composition"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph slavehost["SlpModularCms.Api.Slave"]
|
||||||
|
progslave["C-16 Host composition"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph modules["Modules"]
|
||||||
|
avail["C-13 AvailabilityMiddleware"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph fe["frontend"]
|
||||||
|
cfg["C-14 Config"]
|
||||||
|
feobs["C-15 Sentry plus Umami"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph wf[".gitea/workflows"]
|
||||||
|
ci["C-12 CI workflow"]
|
||||||
|
scp["C-11 deploy-scp"]
|
||||||
|
end
|
||||||
|
|
||||||
|
sechdr --> secopt
|
||||||
|
sechdr --> csp
|
||||||
|
csp --> secopt
|
||||||
|
dp --> appdb
|
||||||
|
migrate --> appdb
|
||||||
|
sentry --> logging
|
||||||
|
|
||||||
|
prog --> sechdr
|
||||||
|
prog --> health
|
||||||
|
prog --> dp
|
||||||
|
prog --> migrate
|
||||||
|
prog --> logging
|
||||||
|
prog --> sentry
|
||||||
|
prog --> statics
|
||||||
|
progslave --> sechdr
|
||||||
|
progslave --> health
|
||||||
|
progslave --> dp
|
||||||
|
progslave --> migrate
|
||||||
|
progslave --> logging
|
||||||
|
progslave --> sentry
|
||||||
|
|
||||||
|
avail --> dp
|
||||||
|
cfg --> feobs
|
||||||
|
ci --> scp
|
||||||
|
ci --> fe
|
||||||
|
ci --> apihost
|
||||||
|
|
||||||
|
classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
|
classDef host fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
|
classDef workflow fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||||
|
class sechdr,secopt,csp,health,dp,appdb,migrate,logging,sentry corelayer;
|
||||||
|
class statics,prog,progslave host;
|
||||||
|
class avail module;
|
||||||
|
class cfg,feobs frontend;
|
||||||
|
class ci,scp workflow;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: all new cross-cutting components live in Core and are consumed by both host projects; only the static mounts are exclusive to the Api host; the Availability middleware depends on Core's Data Protection; and the CI workflow orchestrates the frontend, the Api host and the deploy workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Matrix
|
||||||
|
|
||||||
|
| Component | Depends on | Depended on by | Coupling |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C-01 `SecurityHeadersMiddleware` | C-02, C-03 | C-16 (both hosts) | Compile |
|
||||||
|
| C-02 `SecurityHeadersOptions` | Configuration | C-01, C-03 | Configuration binding |
|
||||||
|
| C-03 `CspPolicyBuilder` | C-02 | C-01 | Compile |
|
||||||
|
| C-04 Health checks | — | C-16 (both hosts) | Compile |
|
||||||
|
| C-05 `CmsDataProtection` | C-06 | C-16, and indirectly C-13 | Compile |
|
||||||
|
| C-06 `ApplicationDbContext` keys table | EF Core, SQL Server | C-05, C-07 | Compile + schema |
|
||||||
|
| C-07 Migration runner | C-06 | C-16 (both hosts) | Compile |
|
||||||
|
| C-08 `CmsLogging` | — | C-09, C-16 | Compile |
|
||||||
|
| C-09 `CmsSentry` | C-08, configuration | C-16 | Compile |
|
||||||
|
| C-10 Static mounts | Filesystem layout | C-16 (`Api` only) | Runtime (filesystem) |
|
||||||
|
| C-11 `deploy-scp.yaml` | Host over SSH | C-12 | Workflow call |
|
||||||
|
| C-12 CI workflow | C-11, both build outputs | — | Workflow |
|
||||||
|
| C-13 `AvailabilityMiddleware` | C-05 (key ring), validated principal | Both hosts, via module discovery | Compile + runtime |
|
||||||
|
| C-14 Frontend config | Vite build variables | C-15, `ApiClient` | Build-time |
|
||||||
|
| C-15 Frontend observability | C-14, Vite build variables | — | Build-time |
|
||||||
|
| C-16 Host composition | C-01, C-04, C-05, C-07, C-08, C-09, C-10 | — | Compile |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communication Patterns
|
||||||
|
|
||||||
|
### In-process (the majority)
|
||||||
|
Everything in `Core` is consumed by the hosts through **DI registration and middleware composition**. There are no new service-to-service calls, no new queues and no new network hops inside the application. This is deliberate: the feature adds cross-cutting behaviour, not new interactions.
|
||||||
|
|
||||||
|
### Configuration-driven
|
||||||
|
`C-02` binds the `SecurityHeaders` section; `C-09` reads a Sentry DSN; `C-05` reads Data Protection settings. All follow the existing Options pattern. **Configuration errors must surface at startup, not per request** — an unknown CSP policy name fails the process rather than silently degrading, which given fail-fast startup (Q8 = A) means a misconfigured deployment goes visibly red instead of quietly serving without protection.
|
||||||
|
|
||||||
|
### Filesystem-coupled (the fragile one)
|
||||||
|
`C-10` depends on a directory layout that no code creates:
|
||||||
|
|
||||||
|
| Path | Owner | Created by |
|
||||||
|
|---|---|---|
|
||||||
|
| `wwwroot/admin/` | This repository | `dotnet publish` (`BuildAndCopyAdminFrontend` target) |
|
||||||
|
| `wwwroot/web/` | **A separate website workspace** | That workspace's own deployment; persists across releases (ASM-01) |
|
||||||
|
|
||||||
|
This is the feature's most fragile coupling, because it is enforced by convention rather than by the type system. Two consequences:
|
||||||
|
1. `C-10` must **tolerate a missing `wwwroot/web/` at startup** — a fresh deployment has none until a website workspace deploys into it, and the CMS must still start and serve `/admin` and `/api/v1`.
|
||||||
|
2. The deployment (S-05) must link the persistent `wwwroot/web/` into each new release directory. Getting this wrong destroys the customer's website — the highest-severity risk in the feature (NFR-02).
|
||||||
|
|
||||||
|
### Build-time coupling (frontend)
|
||||||
|
`C-14` and `C-15` read Vite variables baked in at build time, which is precisely why two artifacts are produced (D-15). The backend and frontend are additionally coupled in the *reverse* direction by the publish target, which runs `pnpm install` and `pnpm build` — making Node and pnpm prerequisites of `dotnet publish` and a required step in the CI workflow's toolchain setup.
|
||||||
|
|
||||||
|
### Cross-instance (unchanged, but newly protected)
|
||||||
|
Master↔slave communication is untouched functionally. What changes is its durability: with `C-05` and `C-06`, the encrypted API keys underpinning that trust survive a redeploy. Previously an atomic release switch would have discarded the file-based key ring and broken the protocol silently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Request flow with the new components
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
box rgba(246,224,94,0.4) Client
|
||||||
|
participant V as Visitor or admin
|
||||||
|
end
|
||||||
|
box rgba(144,205,244,0.4) Pipeline
|
||||||
|
participant E as Exception handler
|
||||||
|
participant S as Security headers
|
||||||
|
participant F as Static files
|
||||||
|
participant A as Availability gate
|
||||||
|
participant H as Health endpoint
|
||||||
|
end
|
||||||
|
V->>E: HTTP request
|
||||||
|
E->>S: continue
|
||||||
|
S->>S: resolve CSP policy for path
|
||||||
|
S->>F: continue with OnStarting callback
|
||||||
|
alt file exists in web or admin mount
|
||||||
|
F-->>V: file, headers applied at response start
|
||||||
|
else no matching file
|
||||||
|
F->>A: continue
|
||||||
|
alt path is /health or another bypass prefix
|
||||||
|
A->>H: continue
|
||||||
|
H-->>V: 200 Healthy or 503 Unhealthy
|
||||||
|
else instance disabled
|
||||||
|
A-->>V: 503 ProblemDetails
|
||||||
|
else instance available
|
||||||
|
A-->>V: routed to controllers or SPA fallback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: security headers register a response-start callback before static files short-circuit, so both static and dynamic responses carry them; `/health` passes the availability gate via the bypass list, while other paths are blocked with a 503 when the instance is disabled.
|
||||||
|
|
||||||
|
### Startup flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
start["Process starts"]
|
||||||
|
log["Configure logging and Sentry"]
|
||||||
|
disc["Discover modules"]
|
||||||
|
svc["Register services<br/>including Data Protection"]
|
||||||
|
build["Build application"]
|
||||||
|
mig["Migrate ApplicationDbContext"]
|
||||||
|
modmig["Module contexts migrate<br/>during UseModules"]
|
||||||
|
serve["Accept traffic;<br/>/health answers"]
|
||||||
|
dead["Process does not start;<br/>/health silent, UptimeRobot red"]
|
||||||
|
|
||||||
|
start --> log
|
||||||
|
log --> disc
|
||||||
|
disc --> svc
|
||||||
|
svc --> build
|
||||||
|
build --> mig
|
||||||
|
mig -->|success| modmig
|
||||||
|
mig -->|failure| dead
|
||||||
|
modmig --> serve
|
||||||
|
|
||||||
|
classDef normal fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||||
|
class start,log,disc,svc,build,modmig,serve normal;
|
||||||
|
class mig decision;
|
||||||
|
class dead bad;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: logging and Sentry are configured first so any later startup failure is captured; Core migrations run before module migrations, and a migration failure stops the process entirely rather than serving a broken application.
|
||||||
|
|
||||||
|
### Deployment data flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
art["Build artifact"]
|
||||||
|
backup[("Database backup<br/>production only")]
|
||||||
|
newrel["New release directory"]
|
||||||
|
persist[("Persistent wwwroot/web<br/>customer website")]
|
||||||
|
active["Active release symlink"]
|
||||||
|
prev["Retained previous release"]
|
||||||
|
|
||||||
|
art --> newrel
|
||||||
|
backup -.->|before any change| newrel
|
||||||
|
persist -->|linked into| newrel
|
||||||
|
newrel --> active
|
||||||
|
active -.->|previous becomes| prev
|
||||||
|
prev -.->|rollback| active
|
||||||
|
|
||||||
|
classDef artifact fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
|
classDef link fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
class art,newrel artifact;
|
||||||
|
class backup,persist store;
|
||||||
|
class active,prev link;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: the build artifact populates a new release directory into which the persistent customer website is linked; the active pointer then switches atomically, and the previous release is retained so a rollback is a pointer switch rather than a rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coupling Concerns
|
||||||
|
|
||||||
|
| Concern | Assessment |
|
||||||
|
|---|---|
|
||||||
|
| **`Core` is inherited by both hosts** | Intended (Q1 = A, Q2 = A). Every `Core` change reaches `SlpModularCms.Api.Slave`, which has no test project — so it is verified at Build and Test by actually starting it. The Slave is a reference instance, not a throwaway. |
|
||||||
|
| **Duplicate `AddDataProtection()` in two modules** | **A real conflict**, documented in `services.md` S-01. Module registration runs *after* host registration, so the modules' calls would override the persistent key store and make FR-12 a no-op that looks implemented. Both must be removed. |
|
||||||
|
| **`AvailabilityMiddleware` runs before `UseAuthentication()`** | Constrains how FR-24 can be implemented — either validate the token in the middleware, or move authentication earlier. Resolved in Functional Design for Unit 2. |
|
||||||
|
| **Filesystem convention for `wwwroot/web/`** | The weakest link: not enforceable in code, and getting it wrong is destructive. Mitigated by design (persistent path outside the release directory) and by documentation (FR-09), but it stays a convention. |
|
||||||
|
| **Static files short-circuit the pipeline** | Dictates that security headers precede them. Any future middleware that must see all responses faces the same constraint — worth remembering rather than rediscovering. |
|
||||||
|
| **Build-time frontend configuration** | Forces two build artifacts. Accepted (D-15), with runtime configuration recorded as a possible later improvement. |
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
# Component Methods
|
||||||
|
|
||||||
|
Method signatures, purpose and input/output types. **Detailed business rules are defined per unit in Functional Design (CONSTRUCTION phase)** — this document establishes the interface contracts only.
|
||||||
|
|
||||||
|
Signatures are indicative C# and may be refined during Code Generation, but the shape of each contract is a design decision recorded here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-01 `SecurityHeadersMiddleware`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SecurityHeadersMiddleware
|
||||||
|
{
|
||||||
|
public SecurityHeadersMiddleware(RequestDelegate next, IOptions<SecurityHeadersOptions> options, CspPolicyBuilder policyBuilder);
|
||||||
|
public Task InvokeAsync(HttpContext context);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `InvokeAsync` | Register a response-start callback that applies the appropriate headers, then continue the pipeline | `HttpContext` | `Task` |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Headers are applied through `HttpResponse.OnStarting`, **not** before calling `next`. The response content type is unknown until the response begins, and HTML-only headers (FU1 = A) cannot be decided without it.
|
||||||
|
- The CSP policy for the request path is resolved once per request, before the callback, so path matching does not run at response-start time.
|
||||||
|
- Existing headers are never overwritten — a downstream component that deliberately set one wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-02 `SecurityHeadersOptions`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SecurityHeadersOptions
|
||||||
|
{
|
||||||
|
public List<PathPolicyRule> PathPolicies { get; set; } = new();
|
||||||
|
public string DefaultPolicy { get; set; } = "Relaxed";
|
||||||
|
public List<string> AllowedScriptOrigins { get; set; } = new();
|
||||||
|
public List<string> AllowedConnectOrigins { get; set; } = new();
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PathPolicyRule
|
||||||
|
{
|
||||||
|
public string PathPrefix { get; set; } = string.Empty;
|
||||||
|
public string Policy { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PathPolicies` | Ordered path-prefix to policy-name assignment. Configuration, so paths can be added without code changes (Q6 = B) |
|
||||||
|
| `DefaultPolicy` | Policy applied when no prefix matches — `Relaxed`, covering the public website |
|
||||||
|
| `AllowedScriptOrigins` | Origins added to the CSP `script-src` directive — the Umami script host |
|
||||||
|
| `AllowedConnectOrigins` | Origins added to `connect-src` — the Sentry ingest host |
|
||||||
|
| `Enabled` | Escape hatch for local development or diagnosis |
|
||||||
|
|
||||||
|
**Design rule (FU2 = A)**: policy *definitions* are in code; only *assignment* and environment-specific *origins* are configuration. A misconfiguration can therefore misroute a path but cannot invent a broken policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-03 `CspPolicyBuilder`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class CspPolicyBuilder
|
||||||
|
{
|
||||||
|
public CspPolicyBuilder(IOptions<SecurityHeadersOptions> options);
|
||||||
|
public string Build(string policyName);
|
||||||
|
public string ResolvePolicyName(PathString path);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Build` | Compose the full CSP header value for a named policy, injecting configured origins | `string policyName` | `string` — the header value |
|
||||||
|
| `ResolvePolicyName` | Determine which policy applies to a request path by prefix match, falling back to `DefaultPolicy` | `PathString` | `string` — policy name |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Policy strings are built **once at startup** and cached by name; `Build` returns the cached value. Composing a CSP per request would be wasteful on a static-file-heavy workload.
|
||||||
|
- Two policies are defined in code: `Strict` (baseline `default-src 'self'`) and `Relaxed` (permissive enough that a website author who never saw this repository is not broken by it — D-31).
|
||||||
|
- An unknown policy name is a configuration error and must fail at startup, not silently fall back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-04 Health-check registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services);
|
||||||
|
public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AddCmsHealthChecks` | Register framework health-check services | `IServiceCollection` | same, for chaining |
|
||||||
|
| `MapCmsHealthChecks` | Map `GET /health` | `IEndpointRouteBuilder` | same, for chaining |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- **No database check and no dependency probes** (D-21). The registration takes no options precisely so that "just add one more check" is a visible code change rather than a configuration drift.
|
||||||
|
- Response is the framework default: `200` with `Healthy`, or `503` with `Unhealthy`.
|
||||||
|
- The endpoint is anonymous and exposes no information beyond liveness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-05 `CmsDataProtection` registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static IServiceCollection AddCmsDataProtection(this IServiceCollection services, IConfiguration configuration);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AddCmsDataProtection` | Configure Data Protection to persist keys in `ApplicationDbContext` with a stable application discriminator | `IServiceCollection`, `IConfiguration` | same, for chaining |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Replaces the bare `services.AddDataProtection()` calls currently made independently by `AvailabilityModule` and `MasterModule`. Those must be removed, or a later registration could silently override the persistent store.
|
||||||
|
- The application discriminator must be **stable and explicit**. By default it derives from the content root path, which changes with every atomic release-directory switch (FR-06) — which would defeat the entire purpose of FR-12.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-06 `ApplicationDbContext` extension
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ApplicationDbContext : IdentityDbContext<...>, IDataProtectionKeyContext
|
||||||
|
{
|
||||||
|
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DataProtectionKeys` | Backing store for the Data Protection key ring, required by `IDataProtectionKeyContext` |
|
||||||
|
|
||||||
|
Requires one new Core migration, applied automatically by C-07.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-07 Startup migration runner
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static WebApplication MigrateCoreDatabase(this WebApplication app);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MigrateCoreDatabase` | Apply pending `ApplicationDbContext` migrations before the app serves traffic | `WebApplication` | same, for chaining |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- **Exceptions propagate (Q8 = A).** No try/catch, no logged-and-continue. A host that cannot reach or migrate its database must not start.
|
||||||
|
- Called before `app.Run()` and before any request is accepted, so no request ever sees a partially migrated schema.
|
||||||
|
- Deliberately covers only `ApplicationDbContext`; the two module contexts already migrate themselves in their `UseModule` implementations, and moving that would change existing behaviour outside this feature's scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-08 `CmsLogging` registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static IHostApplicationBuilder AddCmsLogging(this IHostApplicationBuilder builder);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AddCmsLogging` | Configure structured console logging with a correlation identifier on every entry | `IHostApplicationBuilder` | same, for chaining |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Independent of Sentry (Q10 = B) — structured logging must work with no DSN configured.
|
||||||
|
- The correlation-ID mechanism is **not fixed here**; OPEN-01 is decided in NFR Design for Unit 4.
|
||||||
|
- Must not log secrets, tokens or PII (SECURITY-03).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-09 `CmsSentry` registration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static IHostApplicationBuilder AddCmsSentry(this IHostApplicationBuilder builder);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Purpose | Input | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AddCmsSentry` | Initialise Sentry when a DSN is configured; do nothing when it is not | `IHostApplicationBuilder` | same, for chaining |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Absent DSN is a **normal, supported state**, not an error — local development and any deployment without Sentry must run unchanged with console logging only (FR-14).
|
||||||
|
- Tags events with environment and release.
|
||||||
|
- Security-relevant events for alerting (FR-19) are emitted by application code; what qualifies as alertable is defined in NFR Design for Unit 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-10 Static-file mount composition (`SlpModularCms.Api` only)
|
||||||
|
|
||||||
|
Composed inline in `Program.cs` rather than behind an abstraction, since it is host-specific and there is exactly one host that needs it.
|
||||||
|
|
||||||
|
| Registration | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `UseDefaultFiles` + `UseStaticFiles` with `PhysicalFileProvider(wwwroot/web)` at `/` | Serve the customer's public website |
|
||||||
|
| `UseDefaultFiles` + `UseStaticFiles` with `PhysicalFileProvider(wwwroot/admin)`, `RequestPath = "/admin"` | Serve the admin SPA |
|
||||||
|
| `MapFallbackToFile("/admin/{*path:nonfile}", …)` | Admin SPA client-side routes |
|
||||||
|
| `MapFallbackToFile("{*path:nonfile}", …)` | Public website client-side routes |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Order matters: the `/admin` mount must be registered before the root mount, so `/admin/...` is not captured by the root provider.
|
||||||
|
- The `nonfile` constraint is retained on both fallbacks — a missing asset must still `404` rather than receive HTML (existing behaviour worth preserving deliberately).
|
||||||
|
- Directory browsing stays disabled (SECURITY-09).
|
||||||
|
- Both providers must tolerate a **missing directory at startup**: a fresh deployment has no `wwwroot/web/` until a website workspace deploys into it, and the CMS must still start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-13 `AvailabilityMiddleware` (modified)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static readonly string[] _bypassPrefixes = [ /* existing */, "/health" ];
|
||||||
|
private bool IsAdminBypass(HttpContext context);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Member | Change | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `_bypassPrefixes` | Add `/health` | The availability gate must never mask infrastructure liveness (FR-10, D-22) |
|
||||||
|
| `IsAdminBypass` | Stop using `ReadJwtToken`; rely on a validated principal | Close the forged-token bypass (FR-24, SECURITY-08) |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- Signature is unchanged; only the implementation and the constant change.
|
||||||
|
- **Preserved behaviour**: an Owner or Administrator with a valid token still bypasses the gate, so administrators can always reach a disabled instance.
|
||||||
|
- If the implementation moves to reading `HttpContext.User`, note that `AvailabilityMiddleware` currently runs **before** `UseAuthentication()`. Either authentication must run earlier, or the middleware must validate the token itself with the same parameters as the bearer scheme. **This ordering constraint is the substance of the fix and is resolved in Functional Design for Unit 2.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-14 Frontend configuration (modified)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function getAppConfig(): AppConfig;
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
apiBaseUrl: string; // '' means same-origin
|
||||||
|
appTitle: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Change | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `apiBaseUrl` accepts empty string | Same-origin default when `VITE_API_BASE_URL` is unset (FR-13) |
|
||||||
|
| Zod schema relaxed | Accept either an empty string or a valid absolute URL — **not** any string, so a malformed value is still caught |
|
||||||
|
|
||||||
|
**Interface notes**:
|
||||||
|
- `ApiClient` composes request URLs as `${baseUrl}${path}`, so an empty base yields a root-relative URL — same-origin without further change.
|
||||||
|
- Local development against `https://localhost:7221` (master) or `:7222` (slave) must keep working exactly as today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-15 Frontend observability (new)
|
||||||
|
|
||||||
|
| Element | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| Sentry initialisation in `main.tsx` | Error and performance reporting; skipped when `VITE_SENTRY_DSN` is absent |
|
||||||
|
| Umami script component | Analytics; renders nothing when the website ID is absent or in local development |
|
||||||
|
|
||||||
|
**Interface notes**: both read build-time Vite variables, which is why two separate builds are produced (D-15).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C-11 / C-12 Workflow interfaces
|
||||||
|
|
||||||
|
### `deploy-scp.yaml` (and later `deploy-ftps.yaml`) — `workflow_call` inputs
|
||||||
|
|
||||||
|
| Input | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `artifact_name` | string | Build artifact to download |
|
||||||
|
| `environment` | string | `test` or `production` — used for naming and tagging |
|
||||||
|
| `deploy_path` | string | Target base path on the host |
|
||||||
|
| `release_retention` | number | How many previous releases to retain for fast rollback (FR-06, D-26) |
|
||||||
|
|
||||||
|
Secrets are inherited. **The input interface is identical across transports (Q11 = B)**, so a caller can switch workflow file without changing arguments.
|
||||||
|
|
||||||
|
### `continuous_integration.yaml` — `workflow_dispatch` inputs
|
||||||
|
|
||||||
|
| Input | Type | Default | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `deploy_production` | boolean | `false` | The only route to production (FR-04) |
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
Component definitions and high-level responsibilities. Detailed business logic is designed per unit in Functional Design.
|
||||||
|
|
||||||
|
**Placement rule (Q1 = A)**: every new cross-cutting concern lives in `SlpModularCms.Core` and is exposed as an extension method. Both hosts therefore get identical behaviour with no duplicated implementation. **Q2 = A**: `SlpModularCms.Api.Slave` receives everything except the static-file mounts, because it serves as a reference for what a customer-facing API instance looks like — not merely as a local dev tool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
### C-01 `SecurityHeadersMiddleware`
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Security/`)
|
||||||
|
- **Purpose**: Apply HTTP security headers that would normally come from nginx or IIS configuration, which NFR-01 forbids relying on.
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Attach headers at response start, so the response content type is known before deciding what applies
|
||||||
|
- Apply per-header scoping (FU1 = A): `X-Content-Type-Options` and `Strict-Transport-Security` on **all** responses; `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` on **HTML** responses only
|
||||||
|
- Select the CSP policy for the request path
|
||||||
|
- Never overwrite a header another component has already set
|
||||||
|
- **Interfaces**: standard middleware — `InvokeAsync(HttpContext)`. Registered via `UseCmsSecurityHeaders()`.
|
||||||
|
- **Requirements**: FR-18, SECURITY-04
|
||||||
|
|
||||||
|
### C-02 `SecurityHeadersOptions`
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Security/`)
|
||||||
|
- **Purpose**: Bind the `SecurityHeaders` configuration section (Options pattern, consistent with `JwtSettings`, `MasterModule`, `MasterPolling`).
|
||||||
|
- **Responsibilities**: Carry the path-to-policy assignment, the default policy, and the environment-specific allowed origins.
|
||||||
|
- **Design rule (Q5 = B + Q6 = B, confirmed FU2 = A)**: **policy definitions live in code; path assignment and origins live in configuration.** Adding a path later needs no code change; inventing a new policy does.
|
||||||
|
- **Requirements**: FR-18
|
||||||
|
|
||||||
|
### C-03 `CspPolicyBuilder`
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Security/`)
|
||||||
|
- **Purpose**: Compose a Content-Security-Policy string from a named policy plus the configured origins.
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Define the two named policies in code: `Strict` (for `/admin` and `/api/v1`) and `Relaxed` (for the public website)
|
||||||
|
- Inject the configured Umami script origin and Sentry ingest origin into the relevant directives
|
||||||
|
- Build each policy once at startup rather than per request
|
||||||
|
- **Rationale for existing separately from C-01**: keeps policy composition unit-testable without a request pipeline, and keeps the middleware free of string building.
|
||||||
|
- **Requirements**: FR-18, D-31
|
||||||
|
|
||||||
|
### C-04 Health-check registration
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Health/`)
|
||||||
|
- **Purpose**: Expose infrastructure liveness, strictly separate from CMS domain state.
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Register the framework health-check services (no package required)
|
||||||
|
- Map `GET /health` returning `200`/`Healthy` or `503`/`Unhealthy`
|
||||||
|
- **Liveness only** — no database call, no dependency probing (D-21)
|
||||||
|
- **Explicit non-responsibility**: this component says nothing about availability or capabilities. Those are CMS domain functionality and are never to be used for monitoring.
|
||||||
|
- **Requirements**: FR-10
|
||||||
|
|
||||||
|
### C-05 `CmsDataProtection` registration
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/`)
|
||||||
|
- **Purpose**: Persist the Data Protection key ring in the database so redeploys and atomic release switches cannot render stored slave API keys unreadable.
|
||||||
|
- **Responsibilities**: Configure `PersistKeysToDbContext<ApplicationDbContext>` and set a stable application discriminator so both hosts and all replicas derive the same keys.
|
||||||
|
- **Requirements**: FR-12, D-17
|
||||||
|
|
||||||
|
### C-06 `ApplicationDbContext` extension — `IDataProtectionKeyContext`
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Data/`) — **modification of an existing component**
|
||||||
|
- **Purpose**: Host the Data Protection keys table (Q7 = A).
|
||||||
|
- **Responsibilities**: Add `DbSet<DataProtectionKey> DataProtectionKeys` and implement `IDataProtectionKeyContext`. Requires one new Core migration.
|
||||||
|
- **Why here rather than a fourth context**: keys are application-wide infrastructure, not module-owned, and `ApplicationDbContext` now migrates automatically (FR-11) so the table is created without manual steps.
|
||||||
|
- **Requirements**: FR-12
|
||||||
|
|
||||||
|
### C-07 Startup migration runner
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/`)
|
||||||
|
- **Purpose**: Apply `ApplicationDbContext` migrations at startup, removing the need for CLI access on the host.
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Run `Database.Migrate()` for `ApplicationDbContext` during startup, before the request pipeline accepts traffic
|
||||||
|
- **Fail fast (Q8 = A)**: on failure, let the exception propagate so the process does not start
|
||||||
|
- **Design interaction worth stating**: fail-fast is what makes the liveness-only health check meaningful. A process that cannot migrate never starts, `/health` stops answering, and UptimeRobot goes red. Had this logged-and-continued, the app would look healthy while being unusable.
|
||||||
|
- **Requirements**: FR-11, D-13
|
||||||
|
|
||||||
|
### C-08 `CmsLogging` registration
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Observability/`)
|
||||||
|
- **Purpose**: Configure structured logging, independent of whether Sentry is enabled (Q10 = B).
|
||||||
|
- **Responsibilities**: Console logging with structured output and a correlation/request identifier on every entry; exclude secrets and PII.
|
||||||
|
- **Open item**: OPEN-01 — the correlation-ID mechanism (ASP.NET Core `TraceIdentifier` versus W3C `traceparent`) is decided in NFR Design for Unit 4.
|
||||||
|
- **Requirements**: D-20, SECURITY-03
|
||||||
|
|
||||||
|
### C-09 `CmsSentry` registration
|
||||||
|
- **Project**: `SlpModularCms.Core` (`Hosting/Observability/`)
|
||||||
|
- **Purpose**: Report errors and structured logs to Sentry, tagged by environment.
|
||||||
|
- **Responsibilities**:
|
||||||
|
- Initialise `Sentry.AspNetCore` when a DSN is configured, and **skip silently when it is not**, leaving console logging active
|
||||||
|
- Tag events with environment and release
|
||||||
|
- Emit security-relevant events for alerting (FR-19)
|
||||||
|
- **Separate from C-08 (Q10 = B)**: structured logging must work without Sentry.
|
||||||
|
- **Requirements**: FR-14, FR-19, D-19
|
||||||
|
|
||||||
|
### C-10 Static-file mount composition
|
||||||
|
- **Project**: `SlpModularCms.Api` **only** — host-specific, not in `Core` (Q2 = A: the Slave has no static content)
|
||||||
|
- **Purpose**: Serve two independent front-ends from one process.
|
||||||
|
- **Responsibilities** (Q3 = A):
|
||||||
|
- Mount `wwwroot/web/` at `/` with its own `PhysicalFileProvider`
|
||||||
|
- Mount `wwwroot/admin/` at `/admin` with its own `PhysicalFileProvider`
|
||||||
|
- Provide default-file handling per mount
|
||||||
|
- Map two SPA fallbacks, preserving the `nonfile` route constraint so missing assets still return `404`
|
||||||
|
- **Design consequence**: two explicit registrations rather than one, so each mount can later carry its own headers or caching without disturbing the other.
|
||||||
|
- **Requirements**: FR-07, FR-08, D-06
|
||||||
|
|
||||||
|
### C-11 Deploy transport workflows
|
||||||
|
- **Project**: `.gitea/workflows/` — not C#
|
||||||
|
- **Purpose**: Transfer a published release to a target host.
|
||||||
|
- **Responsibilities** (Q11 = B): one reusable workflow per transport, sharing an identical input interface. `deploy-scp.yaml` is implemented now; `deploy-ftps.yaml` can be added later without changing callers.
|
||||||
|
- **Requirements**: FR-02, D-02, NFR-09
|
||||||
|
|
||||||
|
### C-12 CI workflow
|
||||||
|
- **Project**: `.gitea/workflows/continuous_integration.yaml`
|
||||||
|
- **Purpose**: Validate every change and drive deployment.
|
||||||
|
- **Responsibilities**: Six blocking gates, two environment-specific builds, artifact publication, and the calls into C-11 for test and production.
|
||||||
|
- **Requirements**: FR-01, FR-03, FR-04, FR-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modified Existing Components
|
||||||
|
|
||||||
|
### C-13 `AvailabilityMiddleware`
|
||||||
|
- **Project**: `SlpModularCms.Modules.Availability` (`Middleware/`)
|
||||||
|
- **Changes**:
|
||||||
|
1. Add `/health` to `_bypassPrefixes` so the availability gate cannot mask infrastructure liveness (FR-10, D-22)
|
||||||
|
2. **Fix `IsAdminBypass` to stop trusting an unvalidated token** (FR-24, Q12 = A) — currently `ReadJwtToken` parses without signature verification, so an unauthenticated caller can forge an `Owner` claim and bypass the gate
|
||||||
|
- **Behaviour that must be preserved**: an Owner or Administrator with a *valid* token still passes, so administrators can always reach a disabled instance to switch it back on.
|
||||||
|
- **Requirements**: FR-10, FR-24
|
||||||
|
|
||||||
|
### C-14 Frontend application configuration (`frontend/src/lib/config.ts`)
|
||||||
|
- **Changes**: treat an absent or empty `VITE_API_BASE_URL` as same-origin while still accepting an explicit absolute URL for local development against `https://localhost:7221` / `:7222`. Zod validation relaxed accordingly, without silently accepting malformed values.
|
||||||
|
- **Requirements**: FR-13, D-14
|
||||||
|
|
||||||
|
### C-15 Frontend observability
|
||||||
|
- **Project**: `frontend/src/`
|
||||||
|
- **Changes**: initialise `@sentry/react` with environment and release tags, skipping gracefully without a DSN; add the Umami tracking script with a per-environment website ID, absent during local development.
|
||||||
|
- **Requirements**: FR-15, FR-16
|
||||||
|
|
||||||
|
### C-16 Host composition (`Program.cs`, both hosts)
|
||||||
|
- **Changes**: call the new `Core` extension methods in the correct order. **Q9 = A**: the two files stay separate — with the implementation in `Core`, what remains duplicated is an explicit list of calls, which is intentional readability rather than accidental duplication.
|
||||||
|
- **Requirements**: FR-07, FR-10, FR-11, FR-12, FR-14, FR-18
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Summary
|
||||||
|
|
||||||
|
| ID | Component | Project | Type | Slave gets it? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| C-01 | `SecurityHeadersMiddleware` | Core | New | Yes |
|
||||||
|
| C-02 | `SecurityHeadersOptions` | Core | New | Yes |
|
||||||
|
| C-03 | `CspPolicyBuilder` | Core | New | Yes |
|
||||||
|
| C-04 | Health-check registration | Core | New | Yes |
|
||||||
|
| C-05 | `CmsDataProtection` registration | Core | New | Yes |
|
||||||
|
| C-06 | `ApplicationDbContext` keys table | Core | Modified | Yes |
|
||||||
|
| C-07 | Startup migration runner | Core | New | Yes |
|
||||||
|
| C-08 | `CmsLogging` registration | Core | New | Yes |
|
||||||
|
| C-09 | `CmsSentry` registration | Core | New | Yes |
|
||||||
|
| C-10 | Static-file mount composition | Api | New | **No** |
|
||||||
|
| C-11 | Deploy transport workflows | `.gitea/` | New | n/a |
|
||||||
|
| C-12 | CI workflow | `.gitea/` | New | n/a |
|
||||||
|
| C-13 | `AvailabilityMiddleware` | Modules.Availability | Modified | Yes |
|
||||||
|
| C-14 | Frontend config | frontend | Modified | n/a |
|
||||||
|
| C-15 | Frontend observability | frontend | New | n/a |
|
||||||
|
| C-16 | Host composition | Both hosts | Modified | Yes |
|
||||||
|
|
||||||
|
**14 code components** (9 new, 5 modified) plus **2 workflow components**.
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
# Services and Orchestration
|
||||||
|
|
||||||
|
Service definitions, responsibilities and orchestration patterns.
|
||||||
|
|
||||||
|
This feature introduces few *stateful* services. Its service layer is mostly **composition orchestration**: the order in which registrations and middleware are applied, which for a single process serving three surfaces is where the real design lives. A correct set of components in the wrong order produces security headers that never reach the public website, or a health endpoint that a disabled instance hides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-01 Host composition service (`Program.cs`, both hosts)
|
||||||
|
|
||||||
|
**Responsibility**: compose configuration, services and the request pipeline in an order that satisfies all 24 functional requirements simultaneously.
|
||||||
|
|
||||||
|
**Orchestration pattern**: explicit sequential composition. Per Q9 = A the two hosts keep their own `Program.cs`; with implementations in `Core` (Q1 = A), what is duplicated is a readable list of calls, not logic.
|
||||||
|
|
||||||
|
### Service registration order
|
||||||
|
|
||||||
|
| # | Registration | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `AddJsonFile("appsettings.local.json", optional: true)` | Existing |
|
||||||
|
| 2 | `AddCmsLogging()` | **Early** — so subsequent startup work is already logged structurally |
|
||||||
|
| 3 | `AddCmsSentry()` | Separate from logging (Q10 = B); no-op without a DSN |
|
||||||
|
| 4 | `ModuleOrchestrator.DiscoverModules()` | Existing |
|
||||||
|
| 5 | `AddCoreInfrastructure(configuration)` | Existing — DbContext, Identity, JWT, policies, exception handling |
|
||||||
|
| 6 | `AddCmsDataProtection(configuration)` | **Before module registration** — see the conflict note below |
|
||||||
|
| 7 | `AddCmsCors` / `AddCmsRateLimiting` | Existing |
|
||||||
|
| 8 | `AddCmsHealthChecks()` | New |
|
||||||
|
| 9 | `AddCmsSecurityHeaders(configuration)` | New — binds options, registers `CspPolicyBuilder` |
|
||||||
|
| 10 | `orchestrator.RegisterModuleServices(services)` | Existing |
|
||||||
|
| 11 | `AddControllers(...)` with `ApiPrefixConvention` | Existing |
|
||||||
|
|
||||||
|
> **Registration conflict that must be resolved (step 6 versus step 10)**
|
||||||
|
> `AvailabilityModule.RegisterServices` and `MasterModule.RegisterServices` each call `services.AddDataProtection()` today. Whichever runs last wins the configuration. If those calls remain, module registration at step 10 would silently discard the persistent key store configured at step 6 — and FR-12 would appear implemented while doing nothing.
|
||||||
|
> **Resolution**: remove `AddDataProtection()` from both modules; the host configures Data Protection once. Assigned to Unit 2 and verified in that unit's tests.
|
||||||
|
|
||||||
|
### Middleware pipeline order
|
||||||
|
|
||||||
|
| # | Middleware | Why here |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `UseExceptionHandler()` | Existing — must be outermost to catch everything |
|
||||||
|
| 2 | `UseCmsSecurityHeaders()` | **New.** Before static files, because static files short-circuit the pipeline — anything registered after them never reaches the public website. Placed after the exception handler so error responses also carry headers. Applies per-header scoping at response start (FU1 = A) |
|
||||||
|
| 3 | `UseRateLimiter()` | Existing |
|
||||||
|
| 4 | Dev-only: `MapOpenApi()`, `MapScalarApiReference()` | Existing — Development only (SECURITY-09) |
|
||||||
|
| 5 | `UseHttpsRedirection()` | Existing |
|
||||||
|
| 6 | Static files — `/admin` mount, then `/` mount | New arrangement (C-10). `Api` host only. `/admin` first so it is not captured by the root provider |
|
||||||
|
| 7 | `UseCors()` | Existing |
|
||||||
|
| 8 | `orchestrator.UseModules(app)` → installs `AvailabilityMiddleware` | Existing position. Consequence, deliberately unchanged: the public website is served *before* the availability gate, so a disabled instance still serves the website while blocking `/api/v1` and `/admin` |
|
||||||
|
| 9 | `UseAuthentication()` / `UseAuthorization()` | Existing — but see the FR-24 ordering constraint below |
|
||||||
|
| 10 | `MapControllers()` | Existing |
|
||||||
|
| 11 | `MapCmsHealthChecks()` | New. An endpoint, therefore after the gate — which is exactly why `/health` must be on the bypass list (D-22) |
|
||||||
|
| 12 | Two `MapFallbackToFile` registrations | Existing pattern, retargeted to the two mounts |
|
||||||
|
|
||||||
|
> **Ordering constraint for FR-24**
|
||||||
|
> `AvailabilityMiddleware` (step 8) runs **before** `UseAuthentication()` (step 9), so `HttpContext.User` is not yet populated when the admin bypass is evaluated. Two options, decided in Functional Design for Unit 2:
|
||||||
|
> **(a)** validate the token inside the middleware using the same `TokenValidationParameters` as the bearer scheme, or
|
||||||
|
> **(b)** move `UseAuthentication()` before the module middleware.
|
||||||
|
> Option (b) is a smaller change but alters the pipeline for every module, including any future one — a wider blast radius than this feature should take on. Option (a) is contained but duplicates validation parameters, which must then be shared rather than copied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-02 Security-header application service
|
||||||
|
|
||||||
|
**Responsibility**: decide and apply the correct headers for each response.
|
||||||
|
|
||||||
|
**Orchestration**:
|
||||||
|
1. On request: resolve the policy name for the path via `CspPolicyBuilder.ResolvePolicyName` (prefix match, `DefaultPolicy` fallback)
|
||||||
|
2. Register an `OnStarting` callback carrying that policy name
|
||||||
|
3. At response start, inspect `Content-Type` and apply:
|
||||||
|
- **Always**: `X-Content-Type-Options`, `Strict-Transport-Security`
|
||||||
|
- **HTML responses only**: `Content-Security-Policy`, `X-Frame-Options`, `Referrer-Policy`
|
||||||
|
4. Skip any header already present
|
||||||
|
|
||||||
|
**Why response-start rather than pre-`next`**: the content type is unknown until the response begins, and per-header scoping (FU1 = A) depends on it. Setting headers before calling `next` would force an all-or-nothing choice.
|
||||||
|
|
||||||
|
**Failure behaviour**: header application never throws into the response path. A misconfigured policy is a **startup** failure (unknown policy name), not a per-request one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-03 Startup migration orchestration
|
||||||
|
|
||||||
|
**Responsibility**: bring the database schema to the required version before serving traffic.
|
||||||
|
|
||||||
|
**Orchestration**:
|
||||||
|
1. After `builder.Build()`, before `app.Run()`
|
||||||
|
2. `MigrateCoreDatabase()` applies `ApplicationDbContext` migrations — **fail fast** (Q8 = A)
|
||||||
|
3. `orchestrator.UseModules(app)` triggers the two module contexts' existing `Database.Migrate()` calls
|
||||||
|
|
||||||
|
**Sequencing note**: Core migrates before the modules. All three contexts share one connection string and one database, and the Data Protection keys table lives in `ApplicationDbContext` (Q7 = A) — so the keys table must exist before any module resolves an `IDataProtector`.
|
||||||
|
|
||||||
|
**Failure behaviour**: propagate. The process does not start, `/health` does not answer, UptimeRobot goes red. This is the intended chain and the reason a liveness-only check is sufficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-04 Observability orchestration
|
||||||
|
|
||||||
|
**Responsibility**: make errors and usage visible without host access.
|
||||||
|
|
||||||
|
**Orchestration**:
|
||||||
|
- `AddCmsLogging()` first, so Sentry initialisation problems are themselves logged
|
||||||
|
- `AddCmsSentry()` second, reading the DSN from configuration; **absent DSN is a supported state**, not an error
|
||||||
|
- Both registered before any other service, so startup failures — including a fail-fast migration — are captured
|
||||||
|
|
||||||
|
**Degradation model**: three levels, each fully functional.
|
||||||
|
|
||||||
|
| Configuration | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| No DSN | Structured console logging only |
|
||||||
|
| DSN present | Console plus Sentry, environment-tagged |
|
||||||
|
| DSN present, Sentry unreachable | Sentry's own buffering and drop behaviour; the application is never blocked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-05 Deployment orchestration (`.gitea/workflows/`)
|
||||||
|
|
||||||
|
**Responsibility**: turn a commit into a running release without endangering data the deployment does not own.
|
||||||
|
|
||||||
|
**Orchestration**:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
trigger["Trigger:<br/>PR, push to master,<br/>or workflow_dispatch"]
|
||||||
|
gates["Quality gates<br/>build, test, vulnerability scan,<br/>frontend build, test, lint"]
|
||||||
|
buildtest["Build artifact: test<br/>env-specific Vite vars"]
|
||||||
|
buildprod["Build artifact: production<br/>env-specific Vite vars"]
|
||||||
|
deploytest["deploy-scp: test<br/>auto on master"]
|
||||||
|
deployprod["deploy-scp: production<br/>only with deploy_production"]
|
||||||
|
done(["Running release"])
|
||||||
|
|
||||||
|
trigger --> gates
|
||||||
|
gates --> buildtest
|
||||||
|
gates --> buildprod
|
||||||
|
buildtest --> deploytest
|
||||||
|
buildprod --> deployprod
|
||||||
|
deploytest --> done
|
||||||
|
deployprod --> done
|
||||||
|
|
||||||
|
classDef trig fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
|
classDef gate fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
classDef build fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef deploy fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
|
||||||
|
classDef fin fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
|
class trigger trig;
|
||||||
|
class gates gate;
|
||||||
|
class buildtest,buildprod build;
|
||||||
|
class deploytest,deployprod deploy;
|
||||||
|
class done fin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: every trigger runs the quality gates; a test build always follows and deploys automatically on master, while a production build and deploy run only when the `deploy_production` input is set.
|
||||||
|
|
||||||
|
**Per-deployment sequence** (inside `deploy-scp.yaml`):
|
||||||
|
1. Download the artifact
|
||||||
|
2. **Production only**: take a database backup (FR-20) — before anything is changed
|
||||||
|
3. Upload into a **new** release directory
|
||||||
|
4. Link the persistent `wwwroot/web/` into the new release (ASM-01) — the step that keeps the customer's website alive across the switch
|
||||||
|
5. Switch the active release atomically
|
||||||
|
6. Restart the process
|
||||||
|
7. Verify `/health` responds
|
||||||
|
8. Prune old releases beyond the retention count, keeping at least the previous one (D-26)
|
||||||
|
|
||||||
|
**Rollback**: switch back to the retained previous release directory and restart — no rebuild needed. Forward-compatible, non-destructive migrations are what make this safe (FR-11, D-26).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-06 Transport selection
|
||||||
|
|
||||||
|
**Responsibility**: move files to a host over whatever protocol that host offers.
|
||||||
|
|
||||||
|
**Orchestration (Q11 = B)**: one reusable workflow per transport, all sharing an identical `workflow_call` input interface. `deploy-scp.yaml` exists now; `deploy-ftps.yaml` is added when production moves to shared hosting (D-02, OPEN-04). The caller changes only the `uses:` line — satisfying NFR-09 without building an abstraction for a transport that does not yet exist.
|
||||||
|
|
||||||
|
**Why not one workflow with a `transport` input**: the two transports differ in more than a command — atomic directory switching and process restart are natural over SSH but not available over FTPS, where `app_offline.htm` becomes the mechanism instead. Separate files keep each honest about what it can actually guarantee, rather than hiding a materially different deployment model behind a shared conditional.
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# Unit of Work Dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
u1["U1 Hosting and Serving"]
|
||||||
|
u2["U2 Data Durability"]
|
||||||
|
u3["U3 Security Headers and CSP"]
|
||||||
|
u4["U4 Observability"]
|
||||||
|
u5["U5 CI Workflow and Gates"]
|
||||||
|
u6["U6 Deploy Workflow"]
|
||||||
|
u7["U7 Documentation"]
|
||||||
|
ops["Operations Phase"]
|
||||||
|
|
||||||
|
u1 -->|"path layout for CSP scoping"| u3
|
||||||
|
u4 -->|"Umami and Sentry origins"| u3
|
||||||
|
u4 -->|"env-specific Vite variables"| u5
|
||||||
|
u1 --> u6
|
||||||
|
u2 -->|"durability must precede first deploy"| u6
|
||||||
|
u3 --> u5
|
||||||
|
u5 -->|"invokes"| u6
|
||||||
|
u6 -->|"settled host layout"| u7
|
||||||
|
u6 --> ops
|
||||||
|
u7 --> ops
|
||||||
|
|
||||||
|
classDef r1 fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
|
||||||
|
classDef r2 fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
|
||||||
|
classDef r3 fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
|
classDef r4 fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
|
||||||
|
classDef opsphase fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
|
||||||
|
class u1,u2 r1;
|
||||||
|
class u3,u4 r2;
|
||||||
|
class u5,u6 r3;
|
||||||
|
class u7 r4;
|
||||||
|
class ops opsphase;
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: U1 and U2 are independent and run first; U3 needs U1's path layout and U4's origins; U5 and U6 need the application work complete; U7 needs U6's settled host layout; the Operations phase follows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Matrix
|
||||||
|
|
||||||
|
| Unit | Depends on | Depended on by | Nature of dependency |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **U1** Hosting & Serving | — | U3, U6 | Establishes the path layout that U3's CSP scopes against and U6 deploys into |
|
||||||
|
| **U2** Data Durability | — | U6 | Must land before any automated deploy, or the first atomic switch destroys the key ring |
|
||||||
|
| **U3** Security Headers | U1, U4 | U5 | Needs U1's final paths and U4's external origins |
|
||||||
|
| **U4** Observability | — | U3, U5 | Introduces the origins U3 must permit and the build variables U5 must supply |
|
||||||
|
| **U5** CI Workflow | U3, U4 | U6 | Gates must pass against the finished application; the two builds need U4's variables |
|
||||||
|
| **U6** Deploy Workflow | U1, U2, U5 | U7, Operations | Deploys what the application units produced, invoked by U5 |
|
||||||
|
| **U7** Documentation | U6 | Operations | Documents the layout U6 settles |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordering Constraints That Are Load-Bearing
|
||||||
|
|
||||||
|
These are not preferences. Reordering any of them produces a broken or dangerous result.
|
||||||
|
|
||||||
|
### 1. U2 before U6 — otherwise the first deploy is the dangerous one
|
||||||
|
The atomic release switch (U6) changes the content root path on every deploy. Until U2 configures a database-backed key ring **with an explicit application discriminator**, that switch discards the Data Protection keys and makes every stored slave API key undecryptable. The symptom presents as a network fault between master and slave, so it would be misdiagnosed.
|
||||||
|
|
||||||
|
Deploying first and hardening afterwards means the very first production deploy carries the failure.
|
||||||
|
|
||||||
|
### 2. U4 before or with U3 — otherwise the CSP is written blind
|
||||||
|
U3's `Strict` policy must permit the Umami script origin and the Sentry ingest origin. Those origins are introduced by U4. Writing U3 first means either guessing them or shipping a CSP that blocks the observability U4 then adds — a failure that appears only in a real browser.
|
||||||
|
|
||||||
|
This is why R2 groups them rather than running them in sequence.
|
||||||
|
|
||||||
|
### 3. U1 before U3 — otherwise path scoping is provisional
|
||||||
|
U3 assigns policies by path prefix. Until U1 settles which paths exist and where they are served from, that assignment is written against a layout still in flux.
|
||||||
|
|
||||||
|
### 4. U3 and U4 before U5 — otherwise the gates fail on incomplete work
|
||||||
|
U5's six gates run against the whole application. Switching them on before the application units are complete produces failures that reflect unfinished work rather than defects.
|
||||||
|
|
||||||
|
### 5. U5 with U6 — one interface, two files
|
||||||
|
U6 is a reusable workflow invoked by U5 with a fixed input set. Designing them apart risks an interface mismatch that only surfaces on the first real run.
|
||||||
|
|
||||||
|
### 6. U6 before U7 — documentation cannot precede the layout it documents
|
||||||
|
U7's website contract states target paths, reserved paths and the persistent-directory arrangement. U6 settles those in its Infrastructure Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is *Not* Dependent
|
||||||
|
|
||||||
|
Worth stating explicitly, because it justifies the grouping:
|
||||||
|
|
||||||
|
- **U1 and U2 do not touch each other.** U1 changes serving and middleware; U2 changes persistence and startup. They share `Program.cs` as a file, but not as logic — U1 adds pipeline and endpoint registrations, U2 adds service registration and a startup call. A merge conflict is possible; a behavioural conflict is not.
|
||||||
|
- **U4 does not depend on U1, U2 or U3.** Observability can be added to the application as it stands today.
|
||||||
|
- **U7 does not depend on U3, U4 or U5** beyond describing their results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Resources and Coordination Points
|
||||||
|
|
||||||
|
| Resource | Touched by | Coordination needed |
|
||||||
|
|---|---|---|
|
||||||
|
| `SlpModularCms.Api/Program.cs` | U1, U2, U3, U4 | Four units modify the same file in different places. Registration and pipeline order is specified in `services.md` § S-01, so each unit inserts at a defined position rather than appending |
|
||||||
|
| `SlpModularCms.Api.Slave/Program.cs` | U1 (health only), U2, U3, U4 | Same, minus the static mounts. The Slave is a reference instance (Q2 of Application Design = A) and must keep working; it has no test project, so it is verified by starting it |
|
||||||
|
| `SlpModularCms.Core` | U1, U2, U3, U4 | Each unit adds its own subfolder under `Hosting/` — `Health/`, `Security/`, `Observability/` — so files do not collide |
|
||||||
|
| `AvailabilityModule.cs` / `MasterModule.cs` | U2 | Removing `AddDataProtection()` from both. No other unit touches them |
|
||||||
|
| `appsettings.json` | U2, U3, U4 | Three new sections. Additive, no overlap |
|
||||||
|
| `frontend/` | U4 (features), U5 (lint fixes) | U5's lint fixes touch `AddCmsInstanceDialog.tsx`, `InviteUserDialog.tsx`, `SettingsPage.tsx` and `SetStatusDialog.tsx`; U4 touches `main.tsx`, `config.ts` and adds a Umami component. **No overlapping files** |
|
||||||
|
| `.gitea/workflows/` | U5, U6 | Separate files sharing one input contract |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequence of Merging the Quality Gates into U5
|
||||||
|
|
||||||
|
Q2 = B moved the lint fixes and package pins from a standalone first unit into U5. This is coherent — gates and their prerequisites land in one commit, so the pipeline is never red on arrival — but it has one side effect worth managing:
|
||||||
|
|
||||||
|
**`pnpm run lint` stays failing through U3 and U4**, and U4 changes frontend files. New violations introduced during U4 would therefore hide among the five pre-existing ones.
|
||||||
|
|
||||||
|
**Mitigation**: run lint on the **changed files** during U4 rather than the whole tree. The blocking gate still arrives with U5, but nothing new accumulates in the meantime.
|
||||||
|
|
||||||
|
The overlap is limited: U5's fixes and U4's changes touch disjoint files, so there is no merge risk — only a detection gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Between Units
|
||||||
|
|
||||||
|
Every unit is a self-contained commit on `feature/gitea-deployment-workflow`, with a single pull request at the end (Q6 = A).
|
||||||
|
|
||||||
|
| Unit | Revertible independently? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| U1 | Yes | Pipeline and endpoint registrations |
|
||||||
|
| U2 | Yes, with care | The Core migration adds a table; reverting the code leaves the table in place, which is harmless |
|
||||||
|
| U3 | Yes | Additive middleware plus one config section |
|
||||||
|
| U4 | Yes | Additive |
|
||||||
|
| U5 | Yes | New file plus lint and package changes |
|
||||||
|
| U6 | Yes | New file only |
|
||||||
|
| U7 | Yes | Documentation only |
|
||||||
|
|
||||||
|
**Nothing is deployed to any environment until U6 is complete and explicitly triggered**, so a mid-sequence failure cannot affect a running environment.
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
# Unit of Work — Requirement Map
|
||||||
|
|
||||||
|
**Note on this artifact**: the User Stories stage was skipped for this feature (infrastructure and operations work with no new end-user functionality or persona). Per Q7 = A, this map assigns the **24 functional requirements** to units instead of stories. They serve the same purpose here — they are the units of value being delivered, and mapping them gives complete coverage verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirement-to-Unit Map
|
||||||
|
|
||||||
|
| Requirement | Summary | Unit |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-01 | Continuous integration workflow with six blocking gates | **U5** |
|
||||||
|
| FR-02 | Reusable deploy workflow with transport abstraction | **U6** |
|
||||||
|
| FR-03 | Automatic test deployment on `master` | **U6** |
|
||||||
|
| FR-04 | Production deployment only via explicit `workflow_dispatch` | **U5** |
|
||||||
|
| FR-05 | Separate test and production builds | **U5** |
|
||||||
|
| FR-06 | Atomic release switch with retained previous release | **U6** |
|
||||||
|
| FR-07 | `wwwroot` restructuring — serve `/` from `wwwroot/web/` | **U1** |
|
||||||
|
| FR-08 | The public website must survive every CMS deploy | **U6** |
|
||||||
|
| FR-09 | Website workspace contract | **U7** |
|
||||||
|
| FR-10 | Health-check endpoint, liveness only, on the bypass list | **U1** |
|
||||||
|
| FR-11 | Automatic `ApplicationDbContext` migration at startup | **U2** |
|
||||||
|
| FR-12 | Persistent Data Protection key ring | **U2** |
|
||||||
|
| FR-13 | Same-origin API base URL for the admin SPA | **U4** |
|
||||||
|
| FR-14 | Sentry on the backend | **U4** |
|
||||||
|
| FR-15 | Sentry in the admin SPA | **U4** |
|
||||||
|
| FR-16 | Umami analytics | **U4** |
|
||||||
|
| FR-17 | UptimeRobot monitors | *Operations — Monitoring Setup* |
|
||||||
|
| FR-18 | HTTP security headers with path-scoped CSP | **U3** |
|
||||||
|
| FR-19 | Security alerting | **U4** (event emission) + *Operations* (alert rules) |
|
||||||
|
| FR-20 | Database backup before production deploy | **U6** |
|
||||||
|
| FR-21 | Fix the 5 existing lint errors | **U5** |
|
||||||
|
| FR-22 | Pin the 2 vulnerable packages | **U5** |
|
||||||
|
| FR-23 | Deployment and rollback documentation | *Operations — Deployment Setup* |
|
||||||
|
| FR-24 | Validate the token in the availability gate's admin bypass | **U1** |
|
||||||
|
|
||||||
|
### Split requirements
|
||||||
|
|
||||||
|
Two requirements are deliberately delivered across a boundary rather than assigned wholly to one place:
|
||||||
|
|
||||||
|
- **FR-08** (public website survives) — the *serving* half is U1's `wwwroot/web/` mount, but the requirement is really about deployment behaviour, so it is assigned to **U6** where the persistent-directory linking happens. U1 contributes the precondition.
|
||||||
|
- **FR-19** (security alerting) — the application must *emit* alertable events (U4), and the alert *rules* are configured in Sentry during Operations. Neither half is useful alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Verification
|
||||||
|
|
||||||
|
### By unit
|
||||||
|
|
||||||
|
| Unit | Requirements | Count |
|
||||||
|
|---|---|---|
|
||||||
|
| U1 Hosting & Serving | FR-07, FR-10, FR-24 | 3 |
|
||||||
|
| U2 Data Durability | FR-11, FR-12 | 2 |
|
||||||
|
| U3 Security Headers & CSP | FR-18 | 1 |
|
||||||
|
| U4 Observability | FR-13, FR-14, FR-15, FR-16, FR-19 | 5 |
|
||||||
|
| U5 CI Workflow & Gates | FR-01, FR-04, FR-05, FR-21, FR-22 | 5 |
|
||||||
|
| U6 Deploy Workflow | FR-02, FR-03, FR-06, FR-08, FR-20 | 5 |
|
||||||
|
| U7 Documentation | FR-09 | 1 |
|
||||||
|
| Operations phase | FR-17, FR-23, and the rules half of FR-19 | 2½ |
|
||||||
|
|
||||||
|
**All 24 requirements assigned. None orphaned, none duplicated.**
|
||||||
|
|
||||||
|
U3 carries a single requirement but is not undersized — FR-18 specifies five headers, two policies, path scoping and a configuration surface. Requirement count is not a proxy for effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component-to-Unit Map
|
||||||
|
|
||||||
|
Included as a second coverage check against the 16 Application Design components.
|
||||||
|
|
||||||
|
| Component | Unit |
|
||||||
|
|---|---|
|
||||||
|
| C-01 `SecurityHeadersMiddleware` | U3 |
|
||||||
|
| C-02 `SecurityHeadersOptions` | U3 |
|
||||||
|
| C-03 `CspPolicyBuilder` | U3 |
|
||||||
|
| C-04 Health-check registration | U1 |
|
||||||
|
| C-05 `CmsDataProtection` registration | U2 |
|
||||||
|
| C-06 `ApplicationDbContext` keys table | U2 |
|
||||||
|
| C-07 Startup migration runner | U2 |
|
||||||
|
| C-08 `CmsLogging` registration | U4 |
|
||||||
|
| C-09 `CmsSentry` registration | U4 |
|
||||||
|
| C-10 Static-file mount composition | U1 |
|
||||||
|
| C-11 `deploy-scp.yaml` | U6 |
|
||||||
|
| C-12 `continuous_integration.yaml` | U5 |
|
||||||
|
| C-13 `AvailabilityMiddleware` | U1 |
|
||||||
|
| C-14 Frontend configuration | U4 |
|
||||||
|
| C-15 Frontend observability | U4 |
|
||||||
|
| C-16 Host composition | U1, U2, U3, U4 (each inserts at its defined position) |
|
||||||
|
|
||||||
|
**All 16 components assigned.** C-16 is intentionally shared: four units modify both `Program.cs` files at distinct, specified positions in the registration and pipeline order defined in `services.md` § S-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Items and Open Items Assigned
|
||||||
|
|
||||||
|
| Item | Unit | Resolved at |
|
||||||
|
|---|---|---|
|
||||||
|
| § 5.1 — duplicate `AddDataProtection()` overriding the persistent key store | U2 | Functional Design + Code Generation |
|
||||||
|
| § 5.1 second-order — explicit application discriminator | U2 | Code Generation |
|
||||||
|
| § 5.2 — FR-24 approach: validate in middleware versus move authentication earlier | U1 | Functional Design |
|
||||||
|
| `C-10` must start without `wwwroot/web/` present | U1 | Functional Design |
|
||||||
|
| Unknown CSP policy name must fail at startup | U3 | Functional Design |
|
||||||
|
| OPEN-01 — correlation-ID mechanism | U4 | NFR Design |
|
||||||
|
| Definition of an alertable security event | U4 | NFR Design |
|
||||||
|
| OPEN-03 — patched versions for the two vulnerable packages | U5 | Code Generation |
|
||||||
|
| ASM-01 — `wwwroot/web/` outside the swapped release directory | U6 | Infrastructure Design |
|
||||||
|
| OPEN-04 — when FTPS is actually built | — | Deferred by design (D-02) |
|
||||||
|
|
||||||
|
Every carried-forward item has an owning unit and a resolving stage. Nothing is left to be remembered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Unit Construction Stages
|
||||||
|
|
||||||
|
From the execution plan, adjusted for the new unit boundaries. The original plan's per-unit stage assignments were written against the pre-split numbering; this table is authoritative.
|
||||||
|
|
||||||
|
| Unit | Functional Design | NFR Design | Infrastructure Design | Code Generation |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| U1 Hosting & Serving | **Yes** — fallback precedence, missing-directory behaviour, FR-24 approach | No | No | Yes |
|
||||||
|
| U2 Data Durability | **Yes** — migration failure behaviour, registration-order conflict, discriminator | No | No | Yes |
|
||||||
|
| U3 Security Headers & CSP | **Yes** — policy composition, per-header applicability | **Yes** — SECURITY-04 patterns | No | Yes |
|
||||||
|
| U4 Observability | **Yes** — Sentry-absent behaviour, same-origin resolution | **Yes** — SECURITY-03/14 patterns, correlation ID | No | Yes |
|
||||||
|
| U5 CI Workflow & Gates | No — declarative YAML and mechanical fixes | No | No | Yes |
|
||||||
|
| U6 Deploy Workflow | No — declarative | No | **Yes** — host layout, release directories, ASM-01 | Yes |
|
||||||
|
| U7 Documentation | No | No | **Yes** — depends on U6's layout | Yes |
|
||||||
|
|
||||||
|
**Change from the execution plan**: the plan assigned Functional Design to "units 2, 3, 4" under the old numbering, where old-unit-2 bundled hosting and durability. After the split (Q3 = B), both halves need it — U1 for the FR-24 pipeline-ordering decision and the missing-directory behaviour, U2 for the registration-order conflict and migration failure semantics. Neither is mechanical enough to skip.
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# Units of Work — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
**Decomposition basis**: Q1 = C (split the oversized unit), Q2 = B (merge the quality-gate fixes into the CI unit), Q3 = B (split into Hosting & Serving plus Data Durability)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decomposition Outcome
|
||||||
|
|
||||||
|
The proposed 7-unit split changed in two ways that cancel out numerically:
|
||||||
|
|
||||||
|
- **Unit 2 was split in two** (Q3 = B) — it had bundled three different kinds of work whose only commonality was the deadline "before the first deploy"
|
||||||
|
- **The quality-gate prerequisites were merged into the CI unit** (Q2 = B) — the lint fixes and package pins exist *because* the gates are being switched on, so they land in the same commit as the gates
|
||||||
|
|
||||||
|
Net result: **still 7 units**, but with boundaries drawn along the work rather than along the deadline.
|
||||||
|
|
||||||
|
| # | Unit | Type |
|
||||||
|
|---|---|---|
|
||||||
|
| U1 | Hosting & Serving | Application |
|
||||||
|
| U2 | Data Durability | Application |
|
||||||
|
| U3 | HTTP Security Headers & CSP | Application |
|
||||||
|
| U4 | Observability Integration | Application + Frontend |
|
||||||
|
| U5 | CI Workflow & Quality Gates | Pipeline |
|
||||||
|
| U6 | Deploy Workflow | Pipeline |
|
||||||
|
| U7 | Repository Documentation | Documentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Rounds (Q4 = B)
|
||||||
|
|
||||||
|
Serial where dependent, grouped where independent. **One commit per unit** regardless of grouping (Q6 = A); a round is an approval boundary, not a commit boundary.
|
||||||
|
|
||||||
|
| Round | Units | Why grouped |
|
||||||
|
|---|---|---|
|
||||||
|
| **R1** | U1 + U2 | Mutually independent — one changes serving and middleware, the other changes persistence and startup. Neither reads the other's output |
|
||||||
|
| **R2** | U3 + U4 | Tightly coupled — U3's CSP configuration is populated with the Umami and Sentry origins that U4 introduces. Splitting them means writing a CSP against origins that do not exist yet |
|
||||||
|
| **R3** | U5 + U6 | U6 is invoked by U5; the two workflow files are designed against one shared input interface |
|
||||||
|
| **R4** | U7 | Depends on U6's settled host layout |
|
||||||
|
|
||||||
|
Everything in R1 and R2 must land before R3's deploy workflow can safely run — the reason the durability work is not left until later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U1 — Hosting & Serving
|
||||||
|
|
||||||
|
**Purpose**: make one process serve two independent front-ends correctly, and expose infrastructure liveness that the CMS's own on/off state cannot mask.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- Remount static files: `wwwroot/web/` at `/`, `wwwroot/admin/` at `/admin`, each with its own `PhysicalFileProvider` (Q3 of Application Design = A)
|
||||||
|
- Retarget both SPA fallbacks, preserving the `nonfile` constraint so missing assets still `404`
|
||||||
|
- Tolerate a **missing `wwwroot/web/` at startup** — a fresh deployment has none until a website workspace deploys into it, and the CMS must still start and serve `/admin` and `/api/v1`
|
||||||
|
- Add `AddCmsHealthChecks()` / `MapCmsHealthChecks()` exposing `GET /health`, liveness only, no database call
|
||||||
|
- Add `/health` to `AvailabilityMiddleware._bypassPrefixes`
|
||||||
|
- **Fix `IsAdminBypass`** to stop trusting an unvalidated token (FR-24)
|
||||||
|
|
||||||
|
**Components**: C-04, C-10, C-13, and the U1 portion of C-16
|
||||||
|
**Requirements**: FR-07, FR-10, FR-24
|
||||||
|
**Projects touched**: `SlpModularCms.Core`, `SlpModularCms.Api`, `SlpModularCms.Api.Slave`, `SlpModularCms.Modules.Availability`
|
||||||
|
|
||||||
|
**Carried-in design item**: § 5.2 of `application-design.md` — `AvailabilityMiddleware` runs *before* `UseAuthentication()`, so `HttpContext.User` is unpopulated when the admin bypass is evaluated. Functional Design for this unit decides between validating the token in the middleware (contained, duplicates validation parameters) and moving authentication earlier (smaller change, wider blast radius).
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): builds; all existing tests pass; new tests for `/health` reachability including while the instance is disabled, for the two static mounts' fallback precedence, for graceful startup without `wwwroot/web/`, and for the admin bypass rejecting a forged token while still accepting a valid one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U2 — Data Durability
|
||||||
|
|
||||||
|
**Purpose**: make a redeploy safe. Nothing this unit delivers is visible in normal operation; its entire value is that the atomic release switch in U6 does not silently destroy trust or schema state.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- `ApplicationDbContext` implements `IDataProtectionKeyContext` with a `DataProtectionKeys` set; one new Core migration
|
||||||
|
- `AddCmsDataProtection()` configuring `PersistKeysToDbContext<ApplicationDbContext>`
|
||||||
|
- **Set an explicit application discriminator** — the default derives from the content root path, which changes on every atomic release switch, defeating the purpose by a different route
|
||||||
|
- **Remove `services.AddDataProtection()` from `AvailabilityModule` and `MasterModule`** — module registration runs after the host's, so those calls would override the persistent key store
|
||||||
|
- `MigrateCoreDatabase()` applying `ApplicationDbContext` migrations at startup, **fail fast** on failure (Q8 of Application Design = A)
|
||||||
|
|
||||||
|
**Components**: C-05, C-06, C-07, and the U2 portion of C-16
|
||||||
|
**Requirements**: FR-11, FR-12
|
||||||
|
**Projects touched**: `SlpModularCms.Core`, `SlpModularCms.Modules.Availability`, `SlpModularCms.Modules.Master`, both hosts
|
||||||
|
|
||||||
|
**Carried-in design item**: § 5.1 of `application-design.md` — the duplicate `AddDataProtection()` conflict. This is the unit's highest-value test: without it, FR-12 passes registration tests while remaining ephemeral, and the failure only surfaces later as an apparent network fault between master and slave.
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): builds; all existing tests pass; new tests asserting that the persistent key store **survives module registration**, that the application discriminator is explicit and stable, and that a protected value round-trips across a simulated content-root change. Both hosts start successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U3 — HTTP Security Headers & CSP
|
||||||
|
|
||||||
|
**Purpose**: supply, from inside the application, the headers that would normally come from nginx or IIS configuration — which NFR-01 forbids relying on.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- `SecurityHeadersMiddleware` applying headers at response start via `OnStarting`
|
||||||
|
- Per-header scoping (FU1 = A): `X-Content-Type-Options` and `Strict-Transport-Security` on **all** responses; `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` on HTML responses only
|
||||||
|
- `SecurityHeadersOptions` binding a new `SecurityHeaders` section
|
||||||
|
- `CspPolicyBuilder` with two code-defined policies — `Strict` and `Relaxed` — composed once at startup
|
||||||
|
- Path-to-policy assignment and allowed origins in configuration; policy definitions in code (FU2 = A)
|
||||||
|
- Registration **before static files**, since static files short-circuit the pipeline
|
||||||
|
- An unknown policy name fails at **startup**, not per request
|
||||||
|
|
||||||
|
**Components**: C-01, C-02, C-03, and the U3 portion of C-16
|
||||||
|
**Requirements**: FR-18
|
||||||
|
**Projects touched**: `SlpModularCms.Core`, both hosts
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): builds; all existing tests pass; new tests for policy composition per name, path-to-policy resolution including the default fallback, per-header applicability across HTML and non-HTML responses, headers reaching **static-file responses**, not overwriting pre-set headers, and startup failure on an unknown policy name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U4 — Observability Integration
|
||||||
|
|
||||||
|
**Purpose**: make it possible to tell, without host access, whether the application is erroring and whether it is being used.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- `AddCmsLogging()` — structured logging with a correlation identifier, independent of Sentry (Q10 of Application Design = B)
|
||||||
|
- `AddCmsSentry()` — initialises only when a DSN is configured; absent DSN is a supported state, not an error
|
||||||
|
- Environment and release tagging
|
||||||
|
- Emit security-relevant events for alerting (FR-19)
|
||||||
|
- Frontend: `@sentry/react` initialisation, Umami tracking script with a per-environment website ID, absent in local development
|
||||||
|
- Frontend: `config.ts` treats an absent or empty `VITE_API_BASE_URL` as same-origin while still accepting an explicit absolute URL for local development
|
||||||
|
|
||||||
|
**Components**: C-08, C-09, C-14, C-15, and the U4 portion of C-16
|
||||||
|
**Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19
|
||||||
|
**Projects touched**: `SlpModularCms.Core`, both hosts, `frontend/`
|
||||||
|
|
||||||
|
**Carried-in open item**: OPEN-01 — the correlation-ID mechanism (`TraceIdentifier` versus W3C `traceparent`) is decided in this unit's NFR Design, along with the definition of an alertable security event.
|
||||||
|
|
||||||
|
**Note on lint**: because the quality-gate fixes moved to U5 (Q2 = B), `pnpm run lint` is still failing for pre-existing reasons while this unit changes frontend files. Lint should be run on the **changed files** during this unit so no new violations accumulate, even though the blocking gate is not switched on until U5.
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): builds; all existing tests pass; new tests for logging configuration with correlation ID present, Sentry registration being a no-op without a DSN, same-origin resolution when `VITE_API_BASE_URL` is empty, explicit-URL behaviour preserved, and the Umami component rendering nothing without a website ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U5 — CI Workflow & Quality Gates
|
||||||
|
|
||||||
|
**Purpose**: validate every change, and be the only route to production.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- Fix the 5 frontend lint errors and 1 warning (FR-21) — merged here per Q2 = B, so the gates and the fixes land together and the pipeline is never red on arrival
|
||||||
|
- Pin `Microsoft.OpenApi` and `System.Security.Cryptography.Xml` to patched versions (FR-22, OPEN-03)
|
||||||
|
- `.gitea/workflows/continuous_integration.yaml` with triggers on `pull_request`, `push` to `master`, and `workflow_dispatch` with a `deploy_production` boolean defaulting to `false`
|
||||||
|
- Six blocking gates: backend build, backend tests, vulnerability scan, frontend build, frontend tests, frontend lint and format-check
|
||||||
|
- Toolchain installed explicitly and pinned (`actions/setup-dotnet`, `pnpm/action-setup`); no `latest` tags
|
||||||
|
- Two environment-specific builds with their own Vite variables (FR-05)
|
||||||
|
- Production reachable **only** via `workflow_dispatch` with the flag set (FR-04)
|
||||||
|
|
||||||
|
**Components**: C-12
|
||||||
|
**Requirements**: FR-01, FR-04, FR-05, FR-21, FR-22
|
||||||
|
**Projects touched**: `.gitea/workflows/`, `frontend/src/` (lint fixes), `*.csproj` (package pins)
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): all six gates pass locally against the current tree — `pnpm run lint` clean, `dotnet list package --vulnerable` clean, all tests green. Workflow YAML is syntactically valid. Production cannot be triggered by a push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U6 — Deploy Workflow
|
||||||
|
|
||||||
|
**Purpose**: turn a validated build into a running release without endangering the customer's website, the database, or master↔slave trust.
|
||||||
|
|
||||||
|
**Scope**:
|
||||||
|
- `.gitea/workflows/deploy-scp.yaml` as a reusable `workflow_call` workflow (Q11 of Application Design = B — one workflow per transport, identical input interface)
|
||||||
|
- Plain shell steps, no container actions (they fail on the Podman-backed runner)
|
||||||
|
- Deployment sequence: download artifact → **production only**: database backup before any change → upload to a new release directory → link the persistent `wwwroot/web/` into it → switch the active release atomically → restart the process → verify `/health` → prune old releases keeping at least the previous one
|
||||||
|
- Automatic test deployment on `master`; production deployment only when invoked with the flag
|
||||||
|
- Environment-specific paths from Gitea variables, credentials from secrets
|
||||||
|
|
||||||
|
**Components**: C-11
|
||||||
|
**Requirements**: FR-02, FR-03, FR-06, FR-08, FR-20
|
||||||
|
**Projects touched**: `.gitea/workflows/`
|
||||||
|
|
||||||
|
**Carried-in assumption**: ASM-01 — `wwwroot/web/` must live **outside** the swapped release directory and be linked into each new release. Confirmed in this unit's Infrastructure Design. Getting this wrong destroys the customer's website, the highest-severity risk in the feature.
|
||||||
|
|
||||||
|
**Definition of done** (Q5 = B): workflow YAML valid; the deployment sequence documented step by step including the rollback path; the `wwwroot/web/` linking step explicit and justified. Note that end-to-end verification requires the actual Pi, SSH credentials and a database, so it cannot be fully proven in CI — real-run verification belongs to the Operations phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## U7 — Repository Documentation
|
||||||
|
|
||||||
|
**Purpose**: let a website workspace deliver a site that works, without its author needing to read this repository's code.
|
||||||
|
|
||||||
|
**Scope** (Q8 = A — repository documentation here; operational documents in the Operations phase):
|
||||||
|
- Website workspace contract (FR-09): target path `wwwroot/web/`, required structure, forbidden paths (`admin/`, the application root), reserved paths (`/admin`, `/api/v1`, `/health`), SPA-fallback behaviour, how to call `/api/v1` same-origin without CORS, which CSP applies, and how to include the Umami script
|
||||||
|
- README updates: the new `wwwroot` layout, the health endpoint and what it does *not* mean, the changed production setup section
|
||||||
|
- `frontend/.env.example` updates for the same-origin default and the new observability variables
|
||||||
|
|
||||||
|
**Components**: none — documentation only
|
||||||
|
**Requirements**: FR-09
|
||||||
|
**Projects touched**: repository root, `frontend/`
|
||||||
|
|
||||||
|
**Definition of done**: documentation is accurate against the code as built in U1–U6, and the website contract is complete enough to follow without reading source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Unit Scope — Delivered by the Operations Phase
|
||||||
|
|
||||||
|
| Requirement | Delivered at |
|
||||||
|
|---|---|
|
||||||
|
| FR-17 — UptimeRobot monitors for `/health`, `/` and `/admin` | Monitoring Setup |
|
||||||
|
| FR-19 — Sentry alert rules (the application-side event emission is in U4) | Monitoring Setup |
|
||||||
|
| FR-23 — deployment instructions, host setup, rollback plan, FTPS switch path | Deployment Setup |
|
||||||
|
| DEV-01…04 re-confirmation, appsettings compliance gate | Production Readiness Validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
Brownfield — the existing structure is retained. New code follows the solution layout mandated by `CLAUDE.md` / `AGENTS.md`:
|
||||||
|
|
||||||
|
- Cross-cutting concerns go in `src/SlpModularCms.Core/Hosting/`, in new subfolders `Security/`, `Health/` and `Observability/`
|
||||||
|
- No new project is added to the solution
|
||||||
|
- Workflow files go in `.gitea/workflows/` at the repository root
|
||||||
|
- Tests mirror their production project, per the existing Tests solution folder convention
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
# Application Design Plan — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Stage**: INCEPTION — Application Design
|
||||||
|
**Scope**: high-level component identification, responsibilities, interfaces and service-layer orchestration. Detailed business logic follows per unit in Functional Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Design Steps
|
||||||
|
|
||||||
|
### Step 1: Context analysis
|
||||||
|
- [x] Read `requirements.md` (23 FRs, 10 NFRs, 32 decisions, ASM-01, OPEN-01…04)
|
||||||
|
- [x] Read `execution-plan.md` (7 units, risk level High)
|
||||||
|
- [x] Read the shared reverse-engineering artifacts
|
||||||
|
- [x] Inspect `Program.cs` of both host projects to establish the current composition baseline
|
||||||
|
|
||||||
|
### Step 2: Component identification
|
||||||
|
- [x] Identify new components introduced by this feature
|
||||||
|
- [x] Decide the home project for each (`Core` versus each host) — see Question 1
|
||||||
|
- [x] Establish which components the Slave host inherits and which it must not — see Question 2
|
||||||
|
- [x] Define component boundaries and responsibilities
|
||||||
|
|
||||||
|
### Step 3: Static-file serving redesign
|
||||||
|
- [x] Design the two-mount model (`wwwroot/web/` at `/`, `wwwroot/admin/` at `/admin`) — see Question 3
|
||||||
|
- [x] Define fallback precedence and the `nonfile` constraint behaviour
|
||||||
|
- [x] Establish middleware ordering relative to security headers and the availability gate — see Question 4
|
||||||
|
|
||||||
|
### Step 4: Cross-cutting component interfaces
|
||||||
|
- [x] Define the security-headers component and its configuration surface — see Question 5
|
||||||
|
- [x] Define the CSP path-scoping mechanism — see Question 6
|
||||||
|
- [x] Define health-check registration and its endpoint
|
||||||
|
- [x] Define Data Protection key-ring placement — see Question 7
|
||||||
|
- [x] Define migration-at-startup placement and failure behaviour — see Question 8
|
||||||
|
|
||||||
|
### Step 5: Service layer and orchestration
|
||||||
|
- [x] Define registration extension methods and their composition order
|
||||||
|
- [x] Decide whether shared host composition is extracted — see Question 9
|
||||||
|
- [x] Define the observability registration surface (Sentry, logging) — see Question 10
|
||||||
|
|
||||||
|
### Step 6: Deployment transport abstraction
|
||||||
|
- [x] Design the transport seam that admits FTPS later without restructuring (NFR-09, D-02) — see Question 11
|
||||||
|
|
||||||
|
### Step 7: Scope confirmation
|
||||||
|
- [x] Resolve OPEN-02 ownership — see Question 12
|
||||||
|
|
||||||
|
### Step 8: Mandatory design artifacts
|
||||||
|
- [x] Generate `components.md` — component definitions and high-level responsibilities
|
||||||
|
- [x] Generate `component-methods.md` — method signatures and input/output types
|
||||||
|
- [x] Generate `services.md` — service definitions and orchestration patterns
|
||||||
|
- [x] Generate `component-dependency.md` — dependency matrix, communication patterns, data flow
|
||||||
|
- [x] Generate `application-design.md` — consolidated design document
|
||||||
|
- [x] Validate design completeness and consistency against all 23 FRs
|
||||||
|
- [x] Verify Security Baseline compliance for the design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Design Questions
|
||||||
|
|
||||||
|
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 1 — Waar horen de nieuwe cross-cutting componenten?
|
||||||
|
|
||||||
|
**Context**: `SlpModularCms.Core` wordt door **beide** hosts gebruikt (`Api` en `Api.Slave`). Alles wat je in `Core` registreert, krijgt de Slave er automatisch bij. `Core` heeft al `FrameworkReference: Microsoft.AspNetCore.App`, dus middleware in `Core` kan technisch prima.
|
||||||
|
|
||||||
|
Het gaat om vier nieuwe zaken: health checks, securityheaders-middleware, Data Protection key ring, en Sentry/logging.
|
||||||
|
|
||||||
|
Waar komen die te staan?
|
||||||
|
|
||||||
|
A) Alles in `Core`, aangeboden als extension methods (`AddCmsHealthChecks()`, `AddCmsSecurityHeaders()`, …) — beide hosts krijgen identiek gedrag, geen duplicatie
|
||||||
|
B) Alles in het `Api`-host-project — de Slave is puur een lokaal ontwikkelhulpmiddel en heeft dit niet nodig
|
||||||
|
C) Gesplitst: infrastructuur die beide hosts nodig hebben (Data Protection, health checks, logging) in `Core`; wat alleen met het publieke serveren te maken heeft (securityheaders) in `Api`
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 2 — Wat krijgt de Slave-host wél en niet?
|
||||||
|
|
||||||
|
**Context**: `SlpModularCms.Api.Slave` draait alleen lokaal, heeft geen testproject, en wordt níet gedeployed. Maar hij deelt wel de master↔slave-protocolcode, en juist daar speelt de Data Protection key ring een rol.
|
||||||
|
|
||||||
|
Welke van de nieuwe voorzieningen moet de Slave krijgen?
|
||||||
|
|
||||||
|
A) Alles behalve de statics — dus wél health check, securityheaders, key ring, Sentry; geen `wwwroot/web` of `/admin`. Maximale gelijkenis met productiegedrag
|
||||||
|
B) Alleen wat functioneel nodig is voor het master/slave-protocol: de Data Protection key ring. Geen health check, securityheaders of Sentry — die voegen lokaal niets toe
|
||||||
|
C) Alles wat `Core` biedt (volgt automatisch uit Question 1 = A), en verder niets host-specifieks
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A, Want de Slave is wel een API die laat zien hoe een klant-API eruit kan komen te zien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 3 — Hoe worden de twee statics-mappen bediend?
|
||||||
|
|
||||||
|
**Context**: nu doet `Program.cs` `UseDefaultFiles()` + `UseStaticFiles()` op `wwwroot/`, met twee `MapFallbackToFile`-regels. Met de nieuwe indeling moet `/` uit `wwwroot/web/` komen en `/admin` uit `wwwroot/admin/`.
|
||||||
|
|
||||||
|
A) Twee expliciete `UseStaticFiles`-registraties met elk een eigen `PhysicalFileProvider` en `RequestPath` — expliciet en goed leesbaar, elk pad heeft zijn eigen configuratie (en kan later eigen headers krijgen)
|
||||||
|
B) `WebRootPath` verleggen naar `wwwroot/web` en `/admin` als losse extra mount toevoegen — kleinste wijziging, maar `wwwroot` betekent dan iets anders dan de mapnaam suggereert
|
||||||
|
C) Eén statics-registratie op `wwwroot/` houden en het onderscheid puur via fallback-routes regelen — minste code, maar dan is `wwwroot/web/index.html` ook direct op `/web/index.html` bereikbaar
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 4 — Waar in de pipeline komen de securityheaders?
|
||||||
|
|
||||||
|
**Context**: de huidige volgorde is exception handler → rate limiter → (dev: OpenAPI/Scalar) → HTTPS redirect → statics → CORS → availability-gate → auth → endpoints.
|
||||||
|
|
||||||
|
Statics *short-circuiten*: een bestaand bestand wordt direct geserveerd en alles daarna draait niet meer. Securityheaders die ná de statics staan, komen dus nooit op de publieke website terecht.
|
||||||
|
|
||||||
|
A) Direct vóór de statics — dan krijgen álle responses de headers, inclusief statische bestanden en de publieke website
|
||||||
|
B) Direct ná de exception handler, helemaal vooraan — dan krijgen ook foutresponses de headers
|
||||||
|
C) Alleen op de SPA/HTML-responses, niet op assets — minder overhead op afbeeldingen en scripts
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 5 — Hoe configureerbaar moeten de securityheaders zijn?
|
||||||
|
|
||||||
|
**Context**: de Umami-script-origin en de Sentry-ingest-origin moeten in de CSP toegelaten worden, en die verschillen per omgeving. Configuratie hoort volgens de bestaande stijl in `appsettings` via het Options-patroon.
|
||||||
|
|
||||||
|
A) Volledig via een nieuwe `SecurityHeaders`-sectie in `appsettings.json`, met een typed options-class — consistent met `JwtSettings`, `MasterModule` en de rest
|
||||||
|
B) Vaste, in code ingebakken headers met alleen de CSP-uitzonderingen (Umami/Sentry-origins) configureerbaar — minder knoppen om verkeerd te zetten
|
||||||
|
C) Volledig in code, met de origins afgeleid uit de bestaande Sentry- en Umami-configuratie — geen aparte sectie nodig
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 6 — Hoe wordt de CSP per pad gescopet?
|
||||||
|
|
||||||
|
**Context**: je koos strikt voor `/admin` en `/api/v1`, ruimer voor de publieke website (D-31/CQ5 = B). Dat vraagt een mechanisme dat per request beslist welke CSP geldt.
|
||||||
|
|
||||||
|
A) Padprefix-vergelijking in de middleware: begint het pad met `/admin` of `/api/v1` → strikte policy, anders de ruime — eenvoudig en direct leesbaar
|
||||||
|
B) Een configureerbare lijst van pad-naar-policy-regels in `appsettings`, zodat je later paden kunt toevoegen zonder code te wijzigen
|
||||||
|
C) Twee losse middleware-registraties met `UseWhen()` op padprefix — elk met zijn eigen policy, geen if-logica binnen één component
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 7 — Welke DbContext huisvest de Data Protection keys?
|
||||||
|
|
||||||
|
**Context**: `PersistKeysToDbContext<T>` vereist een `DbContext` die `IDataProtectionKeyContext` implementeert. Er zijn er drie: `ApplicationDbContext` (Core/Identity, migreert straks automatisch), `AvailabilityDbContext` en `MasterDbContext` (beide migreren al automatisch). Ze delen één connection string.
|
||||||
|
|
||||||
|
A) `ApplicationDbContext` — de sleutels zijn infrastructuur van de hele applicatie, niet van één module. Vereist een nieuwe Core-migratie
|
||||||
|
B) Een eigen, nieuwe `DataProtectionDbContext` — maximale scheiding, maar een vierde context en een vierde migratieset
|
||||||
|
C) `AvailabilityDbContext` — die zit het dichtst bij de master/slave-functionaliteit waarvoor de sleutels gebruikt worden
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 8 — Wat gebeurt er als de migratie bij het opstarten faalt?
|
||||||
|
|
||||||
|
**Context**: `ApplicationDbContext` gaat automatisch migreren bij startup (FR-11). De twee modulecontexts doen dat al. De vraag is wat er moet gebeuren als dat misgaat — bijvoorbeeld doordat de database niet bereikbaar is of een migratie stukloopt.
|
||||||
|
|
||||||
|
Dit raakt de health check direct: bij "fail fast" start het proces niet, waardoor `/health` niets teruggeeft en UptimeRobot dus rood wordt — precies wat je wilt weten.
|
||||||
|
|
||||||
|
A) Fail fast — gooi de fout door, het proces start niet. Een half-werkende applicatie is erger dan een zichtbaar dode
|
||||||
|
B) Loggen en toch doorstarten — de applicatie draait, en fouten worden zichtbaar zodra iemand de database aanraakt
|
||||||
|
C) Fail fast in productie, loggen-en-doorstarten in Development — lokaal niet geblokkeerd worden door een migratieprobleem
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 9 — De twee `Program.cs`-bestanden zijn bijna identiek
|
||||||
|
|
||||||
|
**Context**: `SlpModularCms.Api/Program.cs` en `SlpModularCms.Api.Slave/Program.cs` verschillen alleen in de statics/SPA-fallbacks. Deze feature voegt aan beide dezelfde nieuwe registraties toe, waardoor de duplicatie groeit en het risico ontstaat dat ze uit elkaar gaan lopen.
|
||||||
|
|
||||||
|
A) Laat de duplicatie staan — twee losse hosts die expliciet zijn, is duidelijker dan een gedeelde abstractie. Deze feature blijft klein
|
||||||
|
B) Extraheer de gedeelde compositie naar één extension method in `Core` (bijv. `AddCmsHost()` / `UseCmsPipeline()`); elke host voegt alleen zijn eigen specifieke stukken toe
|
||||||
|
C) Extraheer alleen de nieuwe registraties uit deze feature naar gedeelde extension methods, en laat de bestaande duplicatie ongemoeid — kleinste risico, geen regressie in bestaand gedrag
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 10 — Hoe wordt Sentry geregistreerd?
|
||||||
|
|
||||||
|
**Context**: `Sentry.AspNetCore` haakt normaal in op de host-builder. Sentry moet optioneel blijven: zonder DSN geen initialisatie, alleen console-logging (FR-14).
|
||||||
|
|
||||||
|
A) Eén extension method die alles doet (Sentry + logging-configuratie), die zichzelf overslaat als er geen DSN is — één plek om naar te kijken
|
||||||
|
B) Sentry en de logging-configuratie apart registreren, zodat je structured logging ook zonder Sentry kunt aanzetten
|
||||||
|
C) Sentry alleen in het `Api`-host-project, logging-configuratie in `Core`
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 11 — Hoe ziet de transport-abstractie eruit?
|
||||||
|
|
||||||
|
**Context**: nu SSH/SCP, later mogelijk FTPS erbij, zonder de workflow te herstructureren (D-02, NFR-09). Dit is een ontwerpkeuze in de workflow-laag, niet in C#.
|
||||||
|
|
||||||
|
A) `deploy.yaml` krijgt een `transport`-input (`scp` nu, `ftps` later) en kiest intern de juiste stap met een `if`-conditie — één bestand, één interface, transport is een parameter
|
||||||
|
B) Eén reusable workflow per transport (`deploy-scp.yaml`, later `deploy-ftps.yaml`), met een identieke input-interface; de aanroeper kiest welke — schoner gescheiden, iets meer bestanden
|
||||||
|
C) Eén `deploy.yaml` met alleen SCP nu, en de FTPS-variant later toevoegen wanneer die daadwerkelijk nodig is — geen abstractie bouwen voor iets wat er nog niet is
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 12 — Wat doen we met OPEN-02?
|
||||||
|
|
||||||
|
**Context**: `AvailabilityMiddleware.IsAdminBypass` leest de JWT met `ReadJwtToken` **zonder de handtekening te valideren**. Iemand kan dus een zelfgemaakt, ongetekend token met een `Owner`-rol meesturen en daarmee de availability-gate omzeilen. Beveiligde endpoints weigeren daarna alsnog (echte authenticatie gebeurt wel goed), dus dit is geen rechten-escalatie — maar de gate is er wel mee te omzeilen door een niet-geauthenticeerde beller.
|
||||||
|
|
||||||
|
Dit is pre-existent en niet door deze feature veroorzaakt. Onder SECURITY-08 is het wel een echte bevinding.
|
||||||
|
|
||||||
|
A) Meenemen in deze feature — het is een kleine fix (valideer het token, of gebruik het al geauthenticeerde `HttpContext.User`) en het raakt precies de middleware die we toch aanpassen voor de `/health`-bypass
|
||||||
|
B) Doorschuiven naar `tech-debt-backlog` als aparte feature — deze feature blijft over deployment gaan
|
||||||
|
C) Alleen documenteren als geaccepteerd risico met onderbouwing, en verder niets
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Follow-up Questions (Ronde 2)
|
||||||
|
|
||||||
|
Twaalf antwoorden waren eenduidig. Eén punt moet ik terugleggen omdat het letterlijk uitvoeren van je antwoord de beveiliging zou verzwakken, en één punt wil ik bevestigen omdat ik twee antwoorden heb samengevoegd.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Follow-up Question 1 — `nosniff` hoort juist wél op assets
|
||||||
|
|
||||||
|
**Context**: bij Question 4 koos je C: securityheaders alleen op SPA/HTML-responses, niet op assets. Voor de meeste headers klopt dat — een CSP of `X-Frame-Options` op een PNG doet niets.
|
||||||
|
|
||||||
|
**Maar voor twee headers werkt dat averechts:**
|
||||||
|
|
||||||
|
- **`X-Content-Type-Options: nosniff`** bestaat er juist om te voorkomen dat de browser het type van een **niet-HTML-bestand** verkeerd raadt. Een geüpload `.txt`- of `.svg`-bestand dat de browser als HTML of JavaScript interpreteert, is precies het aanvalsscenario. Alleen op HTML zetten haalt de maatregel dus weg waar hij nodig is.
|
||||||
|
- **`Strict-Transport-Security`** is een transportregel voor het hele domein, geen paginaregel. Browsers onthouden hem per host; hem alleen op HTML sturen werkt in de praktijk wel, maar is onnodig fragiel — een bezoeker die als eerste een asset ophaalt, krijgt hem dan niet.
|
||||||
|
|
||||||
|
`Content-Security-Policy`, `X-Frame-Options` en `Referrer-Policy` zijn wél zinvol HTML-only.
|
||||||
|
|
||||||
|
Hoe wil je het?
|
||||||
|
|
||||||
|
A) Per header het passende bereik: `X-Content-Type-Options` en `Strict-Transport-Security` op **alle** responses; `Content-Security-Policy`, `X-Frame-Options` en `Referrer-Policy` alleen op HTML-responses (aanbevolen — dit is wat je met C bedoelde, maar zonder het gat)
|
||||||
|
B) Toch strikt alle vijf de headers alleen op HTML-responses, zoals letterlijk geantwoord
|
||||||
|
C) Alle vijf op alle responses — eenvoudigst, iets meer bytes per asset
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Follow-up Question 2 — Bevestiging van de configuratie-indeling
|
||||||
|
|
||||||
|
**Context**: Question 5 = B ("vaste headers in code, alleen de CSP-uitzonderingen configureerbaar — minder knoppen om verkeerd te zetten") en Question 6 = B ("een configureerbare lijst van pad-naar-policy-regels, zodat je later paden kunt toevoegen zonder code te wijzigen") lijken elkaar tegen te spreken: de een wil weinig configuratie, de ander voegt configuratie toe.
|
||||||
|
|
||||||
|
Ik lees ze als samen consistent, op één manier:
|
||||||
|
|
||||||
|
- De **policy-definities zelf** (wat "strikt" en wat "ruim" betekent) staan **in code** — dat is Question 5 = B
|
||||||
|
- De **toewijzing van pad aan policy** staat **in configuratie**, zodat je later een pad kunt toevoegen zonder code te wijzigen — dat is Question 6 = B
|
||||||
|
- De **uitzonderingsorigins** (Umami-script, Sentry-ingest) staan in configuratie, want ze verschillen per omgeving
|
||||||
|
|
||||||
|
Concreet zou `appsettings` er dan ongeveer zo uitzien:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"SecurityHeaders": {
|
||||||
|
"PathPolicies": [
|
||||||
|
{ "PathPrefix": "/admin", "Policy": "Strict" },
|
||||||
|
{ "PathPrefix": "/api/v1", "Policy": "Strict" }
|
||||||
|
],
|
||||||
|
"DefaultPolicy": "Relaxed",
|
||||||
|
"AllowedScriptOrigins": [ "https://analytics.slpsoftware.nl" ],
|
||||||
|
"AllowedConnectOrigins": [ "https://<sentry-ingest-host>" ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Klopt die lezing?
|
||||||
|
|
||||||
|
A) Ja, precies zo — policies in code, padtoewijzing en origins in configuratie
|
||||||
|
B) Nee, ik wil de policy-inhoud zelf ook configureerbaar (volledige CSP-strings in `appsettings`)
|
||||||
|
C) Nee, ik wil juist minder: ook de padtoewijzing in code, alleen de origins configureerbaar
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Execution Plan — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Feature**: `gitea-deployment-workflow`
|
||||||
|
**Branch**: `feature/gitea-deployment-workflow`
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Detailed Analysis Summary
|
||||||
|
|
||||||
|
### Transformation Scope
|
||||||
|
|
||||||
|
- **Transformation Type**: **Infrastructure and operations transformation with supporting application changes.** This is not a refactor of business logic — it introduces a deployment capability that does not exist today (no `.gitea/` directory) and changes how the application is hosted, configured and observed.
|
||||||
|
- **Primary Changes**:
|
||||||
|
1. New CI/CD pipeline (two workflow files) where none exists.
|
||||||
|
2. A change to the hosting contract: the public website moves from `wwwroot/` to `wwwroot/web/`, altering how `Program.cs` serves static content.
|
||||||
|
3. New cross-cutting application concerns: health checks, HTTP security headers, structured error/log reporting.
|
||||||
|
4. Durability changes: automatic Core migrations and a database-backed Data Protection key ring.
|
||||||
|
5. Deployment-model change: from manual publish to an atomic release-directory switch with a retained previous release.
|
||||||
|
- **Related Components**: `SlpModularCms.Api` (`Program.cs`, `.csproj`), `SlpModularCms.Core` (Data Protection, health checks, security headers, logging), `SlpModularCms.Modules.Availability` (bypass list), `frontend/` (config, Sentry, Umami, lint), plus new `.gitea/workflows/` and documentation.
|
||||||
|
- **No infrastructure-as-code exists** (no CDK, Terraform, CloudFormation, Docker). Host setup is documented procedure, not code — a direct consequence of NFR-01 (no server configuration).
|
||||||
|
|
||||||
|
### Change Impact Assessment
|
||||||
|
|
||||||
|
| Area | Impact | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| **User-facing changes** | **Indirect, yes** | No new features for CMS users. But the public website's URL structure is unchanged while its *storage location* changes (`wwwroot/` → `wwwroot/web/`), and a CSP begins applying to pages that previously had none. A too-strict CSP would visibly break a customer website. |
|
||||||
|
| **Structural changes** | **Yes** | Static-file serving is remounted; new middleware enters the pipeline; a new endpoint (`/health`) is added outside `/api/v1`; the availability bypass list grows. |
|
||||||
|
| **Data model changes** | **Yes, additive** | Data Protection key storage moves into the database, requiring a keys table and a `DbContext` implementing `IDataProtectionKeyContext` — a new migration. No existing entity changes. |
|
||||||
|
| **API changes** | **Minimal, additive** | One new non-versioned endpoint `/health`. No change to any `/api/v1` contract. `frontend`'s API base URL becomes same-origin by default, which changes request URLs the SPA emits but not the endpoints themselves. |
|
||||||
|
| **NFR impact** | **Substantial** | Security (headers, CSP, alerting), reliability (atomic switch, rollback, backup), observability (Sentry, Umami, UptimeRobot), and deployment reproducibility are all new or materially changed. This is where most of the feature's substance lives. |
|
||||||
|
|
||||||
|
### Component Relationships
|
||||||
|
|
||||||
|
- **Primary Component**: `SlpModularCms.Api` — the deployable host and the only place where all three surfaces meet.
|
||||||
|
- **Shared Components**: `SlpModularCms.Core` — receives health-check registration, security-headers middleware, Data Protection configuration and logging setup, because both host projects must inherit them.
|
||||||
|
- **Dependent Components**: `SlpModularCms.Api.Slave` — inherits every `Core` change automatically. **It must keep working**, and it is deliberately excluded from deployment (local-only, no test project).
|
||||||
|
- **Modified Module**: `SlpModularCms.Modules.Availability` — `_bypassPrefixes` must include `/health`.
|
||||||
|
- **Frontend Component**: `frontend/` — config, Sentry, Umami, lint fixes. Coupled to the backend at build time through the publish target.
|
||||||
|
- **Supporting Components (new)**: `.gitea/workflows/`, documentation, and external services (Sentry, Umami, UptimeRobot).
|
||||||
|
|
||||||
|
| Component | Change Type | Change Reason | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SlpModularCms.Api` / `Program.cs` | Major | Static-file remount, health endpoint, middleware order, migrations | Critical |
|
||||||
|
| `SlpModularCms.Core` | Minor (additive) | Health checks, security headers, Data Protection, logging | Critical |
|
||||||
|
| `SlpModularCms.Modules.Availability` | Configuration-only | `/health` bypass | Critical |
|
||||||
|
| `SlpModularCms.Api.Slave` | None (inherits) | Must not regress | Important |
|
||||||
|
| `frontend/` | Minor | Same-origin config, Sentry, Umami, lint fixes | Critical |
|
||||||
|
| `.gitea/workflows/` | New | The feature's core deliverable | Critical |
|
||||||
|
| `*.csproj` (packages) | Minor | Pin vulnerable packages, add Sentry / health-check / Data Protection packages | Critical |
|
||||||
|
| Documentation | New + updates | Website contract, README, operations artifacts | Important |
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
|
||||||
|
- **Risk Level**: **High**
|
||||||
|
- **Rollback Complexity**: **Moderate**
|
||||||
|
- **Testing Complexity**: **Complex**
|
||||||
|
|
||||||
|
**Why High rather than Medium** — three failure modes are destructive and silent:
|
||||||
|
|
||||||
|
1. **Destroying a customer's public website.** An atomic release switch that carries `wwwroot/web/` inside the swapped directory discards the customer's site on every deploy (ASM-01). Data loss, not a bug.
|
||||||
|
2. **Silently breaking master↔slave trust.** Losing the Data Protection key ring makes stored slave API keys undecryptable. The symptom looks like a network fault, so it would be misdiagnosed. FR-12 prevents it — but only if implemented before the first atomic switch.
|
||||||
|
3. **Automatic migrations on startup against production.** FR-11 makes deployment self-contained, and consequently makes a bad migration run automatically with no human gate. This is why FR-20 (pre-deploy backup) and forward-compatible migrations are not optional extras.
|
||||||
|
|
||||||
|
Additional risk factors: the deploy path **cannot be fully tested in CI** — it needs the actual Pi, SSH credentials and a database; and a CSP is a class of change that breaks things only in a real browser, on pages this repository does not own.
|
||||||
|
|
||||||
|
**Mitigations built into the plan**: durability changes (Unit 2) land *before* any deploy workflow (Unit 6); the CSP starts from a known-origin allowlist with the public website deliberately more permissive (D-31); and quality-gate prerequisites (Unit 1) land first so the blocking gates are meaningful rather than immediately red.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Workflow Visualization
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start(["User Request"])
|
||||||
|
|
||||||
|
subgraph INCEPTION["🔵 INCEPTION PHASE"]
|
||||||
|
WD["Workspace Detection<br/><b>COMPLETED</b>"]
|
||||||
|
RE["Reverse Engineering<br/><b>COMPLETED</b>"]
|
||||||
|
RA["Requirements Analysis<br/><b>COMPLETED</b>"]
|
||||||
|
US["User Stories<br/><b>SKIP</b>"]
|
||||||
|
WP["Workflow Planning<br/><b>IN PROGRESS</b>"]
|
||||||
|
AD["Application Design<br/><b>EXECUTE</b>"]
|
||||||
|
UG["Units Generation<br/><b>EXECUTE</b>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CONSTRUCTION["🟢 CONSTRUCTION PHASE"]
|
||||||
|
FD["Functional Design<br/><b>EXECUTE per unit</b>"]
|
||||||
|
NFRA["NFR Requirements<br/><b>SKIP</b>"]
|
||||||
|
NFRD["NFR Design<br/><b>EXECUTE per unit</b>"]
|
||||||
|
ID["Infrastructure Design<br/><b>EXECUTE per unit</b>"]
|
||||||
|
CG["Code Generation<br/>Planning plus Generation<br/><b>EXECUTE</b>"]
|
||||||
|
BT["Build and Test<br/><b>EXECUTE</b>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OPERATIONS["🟡 OPERATIONS PHASE"]
|
||||||
|
DS["Deployment Setup<br/><b>EXECUTE</b>"]
|
||||||
|
MS["Monitoring Setup<br/><b>EXECUTE</b>"]
|
||||||
|
PRV["Production Readiness Validation<br/><b>EXECUTE</b>"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Start --> WD
|
||||||
|
WD --> RE
|
||||||
|
RE --> RA
|
||||||
|
RA --> WP
|
||||||
|
WP --> AD
|
||||||
|
AD --> UG
|
||||||
|
UG --> FD
|
||||||
|
FD --> NFRD
|
||||||
|
NFRD --> ID
|
||||||
|
ID --> CG
|
||||||
|
CG --> BT
|
||||||
|
BT --> DS
|
||||||
|
DS --> MS
|
||||||
|
MS --> PRV
|
||||||
|
PRV --> End(["Complete"])
|
||||||
|
|
||||||
|
style WD fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style RE fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style RA fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style WP fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style CG fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style BT fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
|
||||||
|
style US fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style NFRA fill:#BDBDBD,stroke:#424242,stroke-width:2px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style AD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style UG fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style FD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style NFRD fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style ID fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style DS fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style MS fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style PRV fill:#FFA726,stroke:#E65100,stroke-width:3px,stroke-dasharray: 5 5,color:#000
|
||||||
|
style INCEPTION fill:#BBDEFB,stroke:#1565C0,stroke-width:3px,color:#000
|
||||||
|
style CONSTRUCTION fill:#C8E6C9,stroke:#2E7D32,stroke-width:3px,color:#000
|
||||||
|
style OPERATIONS fill:#FFF59D,stroke:#F57F17,stroke-width:3px,color:#000
|
||||||
|
style Start fill:#CE93D8,stroke:#6A1B9A,stroke-width:3px,color:#000
|
||||||
|
style End fill:#CE93D8,stroke:#6A1B9A,stroke-width:3px,color:#000
|
||||||
|
|
||||||
|
linkStyle default stroke:#333,stroke-width:2px
|
||||||
|
```
|
||||||
|
|
||||||
|
Text alternative: Inception is complete except Application Design and Units Generation which will execute; User Stories is skipped. Construction runs Functional Design, NFR Design and Infrastructure Design per unit (NFR Requirements skipped), then Code Generation and Build and Test. All three Operations stages execute.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phases to Execute
|
||||||
|
|
||||||
|
### 🔵 INCEPTION PHASE
|
||||||
|
|
||||||
|
- [x] **Workspace Detection** — COMPLETED
|
||||||
|
- [x] **Reverse Engineering** — COMPLETED (full rerun, all 8 shared artifacts, verified by executing build/test/lint)
|
||||||
|
- [x] **Requirements Analysis** — COMPLETED (23 FRs, 10 NFRs, 32 decisions, 2 question rounds)
|
||||||
|
- [x] **User Stories** — **SKIP**
|
||||||
|
- **Rationale**: This is infrastructure and operations work. It introduces no new end-user functionality, no new persona, and no user journey. The only user-visible effects are a storage-location change and a CSP, both of which are already captured as requirements with acceptance-relevant detail (FR-07, FR-08, FR-18, FR-09). Personas here would be "the developer deploying" and "the website builder consuming the contract" — the latter is properly served by FR-09's documented contract, not by a story. Offered explicitly at Requirements Analysis approval and not requested.
|
||||||
|
- [~] **Workflow Planning** — IN PROGRESS (this document)
|
||||||
|
- [ ] **Application Design** — **EXECUTE**
|
||||||
|
- **Rationale**: New cross-cutting components genuinely need placement decisions that are not obvious. Where do health checks, security headers and Data Protection registration live — `Core` (inherited by both hosts, including the Slave) or `Api` (host-specific)? Getting this wrong either breaks the Slave or duplicates code. Static-file serving must be redesigned for two mounts with two fallbacks and correct ordering relative to the availability gate. The CSP needs a path-scoping mechanism that does not exist yet. And the deploy transport needs an interface that admits FTPS later without restructuring (NFR-09). These are component-boundary and service-layer decisions — exactly this stage's purpose.
|
||||||
|
- [ ] **Units Generation** — **EXECUTE**
|
||||||
|
- **Rationale**: The work spans application code, frontend code, two workflow files, package changes and documentation, with a **mandatory ordering constraint**: durability changes must land before the first automated deploy, and quality-gate fixes must land before blocking gates are switched on. Sequencing this is load-bearing, not bookkeeping. Seven units are proposed in § 5.
|
||||||
|
|
||||||
|
### 🟢 CONSTRUCTION PHASE
|
||||||
|
|
||||||
|
- [ ] **Functional Design** — **EXECUTE for units 2, 3, 4; SKIP for units 1, 5, 6, 7**
|
||||||
|
- **Rationale**: Units 2, 3 and 4 contain real behavioural logic that needs designing before coding — static-file resolution and fallback precedence, migration-at-startup failure behaviour, CSP composition per path, and what a "security-relevant event" is for alerting. Units 1 (lint fixes, package pins), 5 and 6 (declarative YAML) and 7 (documentation) have no business logic to design; a design document there would restate the requirement.
|
||||||
|
- [ ] **NFR Requirements** — **SKIP (all units)**
|
||||||
|
- **Rationale**: NFRs are already captured comprehensively and with traceability in `requirements.md` § 5 (NFR-01…10) and § 6 (full SECURITY-01…15 assessment with four documented deviations). The tech stack is fixed and unchanged. Re-deriving NFRs per unit would duplicate an artifact that already exists at higher quality than a per-unit restatement would produce.
|
||||||
|
- [ ] **NFR Design** — **EXECUTE for units 3 and 4; SKIP for units 1, 2, 5, 6, 7**
|
||||||
|
- **Rationale, including a deliberate deviation**: The workflow's default is that NFR Design is skipped when NFR Requirements is skipped. I am overriding that coupling for two units, because for them the NFR *is* the deliverable: Unit 3 implements SECURITY-04 (how a CSP is composed and path-scoped, how HSTS interacts with the hosting proxy) and Unit 4 implements SECURITY-03 and SECURITY-14 (structured-logging shape, correlation ID per OPEN-01, which events warrant alerting, PII exclusion). Those are pattern decisions, not requirement decisions — so skipping the requirements stage while designing the patterns is the correct split here, not an oversight. For all other units the existing NFRs need no new pattern work.
|
||||||
|
- [ ] **Infrastructure Design** — **EXECUTE for units 6 and 7; SKIP for units 1, 2, 3, 4, 5**
|
||||||
|
- **Rationale**: Unit 6 is where the host layout is decided — release-directory scheme, where `wwwroot/web/` lives so ASM-01 holds, the symlink or mount strategy, process restart, database backup placement, and the transport abstraction. Unit 7's website contract depends on that layout being settled. This is genuine infrastructure design even though it produces documented procedure rather than IaC (NFR-01 forbids server configuration, so there is nothing to codify). Skipped elsewhere: those units change application code and CI definitions, not infrastructure.
|
||||||
|
- [ ] **Code Generation** — **EXECUTE (always, per unit)**
|
||||||
|
- **Rationale**: Implementation planning and code generation are needed for all seven units. Each unit is built and its own tests run before its completion message.
|
||||||
|
- [ ] **Build and Test** — **EXECUTE (always)**
|
||||||
|
- **Rationale**: Full cross-unit build plus everything that only appears once units are combined — middleware ordering with the new security headers and health endpoint, the Slave host still starting correctly, the frontend building against the same-origin config, and the publish target producing the expected `wwwroot` layout.
|
||||||
|
|
||||||
|
### 🟡 OPERATIONS PHASE
|
||||||
|
|
||||||
|
- [ ] **Deployment Setup** — **EXECUTE**
|
||||||
|
- [ ] **Monitoring Setup** — **EXECUTE**
|
||||||
|
- [ ] **Production Readiness Validation** — **EXECUTE**
|
||||||
|
- **Rationale**: `## Operations Configuration` = **Yes**, decided at Requirements Analysis. For this feature Operations is the centre of gravity, not an afterthought: FR-17 (UptimeRobot monitors), FR-19 (Sentry alert rules), FR-20 (pre-deploy database backup) and FR-23 (deployment, host-setup and rollback documentation, including the FTPS switch path) are all delivered here. Production Readiness Validation will also run the `dotnet-appsettings` compliance gate, which is directly relevant given D-16 (host environment variables) and the placeholder values in `appsettings.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Multi-Module Coordination
|
||||||
|
|
||||||
|
### Module Update Strategy
|
||||||
|
|
||||||
|
- **Update Approach**: **Sequential with two parallelisable pairs.**
|
||||||
|
- **Critical Path**: `SlpModularCms.Core` → `SlpModularCms.Api` → `.gitea/workflows/`. Core carries the shared cross-cutting registrations; the Api host composes them and defines serving; the workflows can only deploy something that exists and is durable.
|
||||||
|
- **Coordination Points**:
|
||||||
|
- `Core` ↔ `Api.Slave`: every `Core` change is inherited by the Slave host. The Slave must still start and serve; it has no test project, so this is verified at Build and Test by starting it.
|
||||||
|
- `Core` ↔ `Modules.Availability`: the `/health` bypass must land in the same unit as the health endpoint, or `/health` returns 503 on a disabled instance — the exact conflation the user corrected.
|
||||||
|
- `Api` ↔ `frontend`: coupled bidirectionally — the SPA calls the API at runtime, and the API's publish target builds the SPA. A same-origin config change (Unit 4) and the `wwwroot` remount (Unit 2) must agree on where `/admin` is served from.
|
||||||
|
- Package pinning (Unit 1) touches multiple `.csproj` files and must not conflict with the new packages added in Units 2, 3 and 4.
|
||||||
|
- **Testing Checkpoints**: after each unit (automatic per-unit build + test), plus a full-solution checkpoint at Build and Test that additionally starts both hosts and verifies the publish output layout.
|
||||||
|
- **Rollback Strategy (mid-sequence)**: each unit is a self-contained commit on `feature/gitea-deployment-workflow`. Units 1–4 are revertible independently. Units 5 and 6 add new files only (`.gitea/`) and are revertible by deletion. Nothing is deployed to any environment until Unit 6 is complete and explicitly triggered, so a mid-sequence failure cannot affect a running environment.
|
||||||
|
|
||||||
|
### Per-Module Detail
|
||||||
|
|
||||||
|
| Module | Priority | Depends on | Depended on by | Change Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `SlpModularCms.Core` | Must-update-first | — | Both hosts, all modules | Minor (additive) |
|
||||||
|
| `SlpModularCms.Api` | Must-update-first | Core | Deployment | Major |
|
||||||
|
| `SlpModularCms.Modules.Availability` | Must-update-with-Core | Core | Both hosts | Patch (bypass list) |
|
||||||
|
| `SlpModularCms.Api.Slave` | Can-update-later (verify only) | Core | — | None (inherits) |
|
||||||
|
| `frontend` | Must-update-before-CI | Api (same-origin contract) | Api publish target | Minor |
|
||||||
|
| `.gitea/workflows/` | Update-last | Everything above | — | New |
|
||||||
|
| Documentation | Update-last | Infrastructure Design | — | New + updates |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Proposed Unit Sequence
|
||||||
|
|
||||||
|
Final unit definitions are produced by Units Generation; this is the sequence the plan is built around, with the ordering constraints that make it non-arbitrary.
|
||||||
|
|
||||||
|
| # | Unit | Delivers | Why here |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Quality Gate Prerequisites** | Fix 5 lint errors (FR-21); pin `Microsoft.OpenApi` and `System.Security.Cryptography.Xml` (FR-22) | Blocking gates are switched on in Unit 5. If this does not land first, the pipeline is red on arrival and the gates get disabled "temporarily". Independent of everything else, so it costs nothing to do first. |
|
||||||
|
| 2 | **Hosting Layout & Data Durability** | `wwwroot/web/` remount and dual SPA fallbacks (FR-07, FR-08); `/health` + bypass (FR-10); automatic Core migrations (FR-11); `PersistKeysToDbContext` (FR-12) | Must precede any automated deploy. The key ring and the `wwwroot` split are exactly what make an atomic switch non-destructive; deploying first and fixing after means the first deploy is the dangerous one. |
|
||||||
|
| 3 | **HTTP Security Headers & CSP** | Security-headers middleware, path-scoped CSP (FR-18) | Needs Unit 2's final path layout to scope the CSP. Precedes Unit 4 so the CSP mechanism exists when Umami and Sentry origins need allowing. |
|
||||||
|
| 4 | **Observability Integration** | Sentry backend + frontend (FR-14, FR-15); structured logging (D-20, OPEN-01); Umami (FR-16); same-origin SPA config (FR-13) | Adds the external origins that Unit 3's CSP must permit, and the environment-tagged build variables that Unit 5's two builds must supply. |
|
||||||
|
| 5 | **CI Workflow** | `continuous_integration.yaml`: triggers, six blocking gates, two environment-specific builds, artifacts (FR-01, FR-05) | Gates are meaningful only after Unit 1; the two builds need Unit 4's variables. |
|
||||||
|
| 6 | **Deploy Workflow** | `deploy.yaml`: reusable, transport-abstracted, atomic switch, restart, retained previous release, backup hook (FR-02, FR-03, FR-04, FR-06, FR-20) | Last executable piece; depends on everything above being durable and buildable. |
|
||||||
|
| 7 | **Repository Documentation** | Website workspace contract (FR-09); README updates for the new `wwwroot` layout and health endpoint; `.env.example` updates | Depends on Unit 6's settled host layout. Operations-facing documents (deployment instructions, rollback plan, monitoring setup, readiness checklist) are produced in the Operations phase, not here. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Timeline
|
||||||
|
|
||||||
|
- **Total stages to execute**: 12 (2 remaining Inception + 7 Construction stage-instances across units + Build and Test + 3 Operations)
|
||||||
|
- **Per-unit Construction stage-instances**: Functional Design ×3, NFR Design ×2, Infrastructure Design ×2, Code Generation ×7
|
||||||
|
- **Estimated duration**: not estimated in wall-clock time. Progress is gated on user approval at every stage boundary, and three items depend on external systems outside this workflow's control: Sentry project and alert-rule creation, Umami website entries, and UptimeRobot monitors. Those are host/service setup steps documented in Operations, not code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Success Criteria
|
||||||
|
|
||||||
|
### Primary Goal
|
||||||
|
A push to `master` builds, tests and deploys the CMS to the test environment without manual steps, and production can be deployed by one deliberate action — on hosting where no server configuration is possible, and without ever endangering the customer's public website, the database, or master↔slave trust.
|
||||||
|
|
||||||
|
### Key Deliverables
|
||||||
|
1. `.gitea/workflows/continuous_integration.yaml` and `.gitea/workflows/deploy.yaml`
|
||||||
|
2. `wwwroot/admin/` + `wwwroot/web/` serving, with `wwwroot/web/` outside the swapped release directory (ASM-01)
|
||||||
|
3. `/health` liveness endpoint, on the availability bypass list, never conflated with domain endpoints
|
||||||
|
4. Sentry on both sides, Umami on both frontends, UptimeRobot monitors for `/health`, `/` and `/admin`
|
||||||
|
5. HTTP security-headers middleware with a path-scoped CSP
|
||||||
|
6. Database-backed Data Protection key ring and automatic Core migrations
|
||||||
|
7. Website workspace contract, deployment instructions, rollback plan, monitoring setup, production readiness checklist
|
||||||
|
8. A green pipeline: lint clean, no vulnerable packages, all tests passing
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
- `dotnet build -c Release`: 0 errors
|
||||||
|
- `dotnet test`: all backend tests pass (219 at baseline, plus new tests)
|
||||||
|
- `dotnet list package --vulnerable --include-transitive`: no advisories
|
||||||
|
- `pnpm run build`: succeeds (includes `tsc -b`)
|
||||||
|
- `pnpm test`: all frontend tests pass (213 at baseline, plus new tests)
|
||||||
|
- `pnpm run lint` and `pnpm run format:check`: clean
|
||||||
|
- No blocking Security Baseline findings at any stage
|
||||||
|
|
||||||
|
### Integration Readiness
|
||||||
|
- Both hosts start successfully — `SlpModularCms.Api` **and** `SlpModularCms.Api.Slave`, the latter having no test project and therefore verified by starting it
|
||||||
|
- `dotnet publish` produces the expected `wwwroot` layout with the admin SPA in place
|
||||||
|
- Middleware ordering verified: exception handler → rate limiter → HTTPS redirect → security headers → static files → CORS → availability gate → auth → endpoints, with `/health` reachable while the instance is switched off
|
||||||
|
- The availability gate still blocks `/api/v1` and `/admin` when disabled, and the public website still serves — the pre-existing behaviour recorded in `architecture.md`, deliberately unchanged
|
||||||
|
|
||||||
|
### Operational Readiness
|
||||||
|
- A deploy can be rolled back by redeploying the retained previous release without a rebuild
|
||||||
|
- A production deploy is preceded by a verifiable database backup
|
||||||
|
- After a deploy it is determinable, without host access, that the app is alive, whether it is erroring, and whether all expected modules loaded — the last point mattering because `ModuleOrchestrator` logs rather than throws on module load failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Carried-Forward Items
|
||||||
|
|
||||||
|
| ID | Item | Handled at |
|
||||||
|
|---|---|---|
|
||||||
|
| ASM-01 | `wwwroot/web/` must live outside the swapped release directory | Infrastructure Design, Unit 6 — **must be confirmed there** |
|
||||||
|
| OPEN-01 | Correlation/request ID mechanism (SECURITY-03) | NFR Design, Unit 4 |
|
||||||
|
| OPEN-02 | `IsAdminBypass` reads the JWT without validating its signature — pre-existing, needs an owner | **Recommend `tech-debt-backlog`**; decide at Application Design whether to fold in |
|
||||||
|
| OPEN-03 | Exact patched versions for the two vulnerable packages | Code Generation, Unit 1 |
|
||||||
|
| OPEN-04 | When FTPS is actually built | Deferred by design (D-02) |
|
||||||
|
| DEV-01…04 | Four accepted security deviations | Re-confirmed at Production Readiness Validation |
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Unit of Work Plan — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Stage**: INCEPTION — Units Generation (Part 1: Planning)
|
||||||
|
|
||||||
|
The execution plan proposed a 7-unit decomposition. This plan confirms or adjusts those boundaries before generating the unit artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Decomposition Steps
|
||||||
|
|
||||||
|
### Step 1: Context analysis
|
||||||
|
- [x] Read `requirements.md` (24 FRs, 10 NFRs after FR-24 was added)
|
||||||
|
- [x] Read `execution-plan.md` § 5 (proposed unit sequence)
|
||||||
|
- [x] Read all five Application Design artifacts, including the two composition conflicts
|
||||||
|
|
||||||
|
### Step 2: Confirm unit boundaries
|
||||||
|
- [x] Confirm or adjust the proposed 7-unit split — see Questions 1, 2, 3
|
||||||
|
- [x] Assign every component (C-01…C-16) to exactly one unit
|
||||||
|
- [x] Assign every functional requirement (FR-01…FR-24) to exactly one unit
|
||||||
|
- [x] Verify no requirement or component is orphaned or duplicated
|
||||||
|
|
||||||
|
### Step 3: Establish dependencies and sequencing
|
||||||
|
- [x] Build the inter-unit dependency matrix
|
||||||
|
- [x] Confirm the ordering constraints that make the sequence non-arbitrary
|
||||||
|
- [x] Identify any units that could run in parallel — see Question 4
|
||||||
|
|
||||||
|
### Step 4: Define per-unit completion criteria
|
||||||
|
- [x] Define what "done" means per unit — see Question 5
|
||||||
|
- [x] Assign the two Application Design conflicts to their units
|
||||||
|
- [x] Assign the remaining open items (OPEN-01, OPEN-03, ASM-01) to their units
|
||||||
|
|
||||||
|
### Step 5: Version control strategy
|
||||||
|
- [x] Establish commit and review granularity — see Question 6
|
||||||
|
|
||||||
|
### Step 6: Mandatory unit artifacts
|
||||||
|
- [x] Generate `unit-of-work.md` — unit definitions and responsibilities
|
||||||
|
- [x] Generate `unit-of-work-dependency.md` — dependency matrix
|
||||||
|
- [x] Generate `unit-of-work-story-map.md` — requirement-to-unit mapping (see Question 7)
|
||||||
|
- [x] Validate unit boundaries and dependencies
|
||||||
|
- [x] Ensure all requirements are assigned to units
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Decomposition Questions
|
||||||
|
|
||||||
|
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 1 — Klopt de opdeling in 7 units?
|
||||||
|
|
||||||
|
**Context**: dit is de voorgestelde indeling uit het uitvoeringsplan.
|
||||||
|
|
||||||
|
| # | Unit | Bevat |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Quality Gate Prerequisites | 5 lint-fixes, 2 packages pinnen |
|
||||||
|
| 2 | Hosting Layout & Data Durability | `wwwroot/web`, `/health`, auto-migratie, key ring, gate-fix |
|
||||||
|
| 3 | HTTP Security Headers & CSP | middleware, policies, configuratie |
|
||||||
|
| 4 | Observability Integration | Sentry backend + frontend, Umami, same-origin config |
|
||||||
|
| 5 | CI Workflow | `continuous_integration.yaml` |
|
||||||
|
| 6 | Deploy Workflow | `deploy-scp.yaml`, atomaire switch, backup |
|
||||||
|
| 7 | Repository Documentation | website-contract, README, `.env.example` |
|
||||||
|
|
||||||
|
A) Ja, 7 units zoals voorgesteld
|
||||||
|
B) Minder units — voeg samen wat bij elkaar hoort (zie ook vraag 2 en 3)
|
||||||
|
C) Meer units — unit 2 is te groot en moet gesplitst (zie vraag 3)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 2 — Moet unit 1 een eigen unit zijn?
|
||||||
|
|
||||||
|
**Context**: unit 1 is klein — 5 lint-errors oplossen en 2 packages pinnen. Het staat los van al het andere. De reden om het apart en als eerste te doen: unit 5 zet blokkerende gates aan, en als deze fixes er dan nog niet zijn, is de pipeline meteen rood.
|
||||||
|
|
||||||
|
A) Ja, eigen unit en als eerste — de fixes zijn onafhankelijk, en een aparte commit maakt duidelijk wat pre-existente schuld was en wat nieuw werk is
|
||||||
|
B) Voeg samen met unit 5 (CI Workflow) — de fixes bestaan alleen omdat de gates komen, dus hoort het bij elkaar
|
||||||
|
C) Voeg samen met unit 2 — gewoon alle applicatiewijzigingen bij elkaar
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 3 — Is unit 2 te groot?
|
||||||
|
|
||||||
|
**Context**: unit 2 bevat vier losse dingen die alleen gemeen hebben dat ze vóór de eerste deploy klaar moeten zijn:
|
||||||
|
|
||||||
|
1. `wwwroot/web`-herindeling en SPA-fallbacks (FR-07, FR-08)
|
||||||
|
2. `/health`-endpoint plus bypass (FR-10)
|
||||||
|
3. Automatische Core-migratie (FR-11)
|
||||||
|
4. Data Protection key ring (FR-12) — inclusief het conflict met de dubbele `AddDataProtection()`
|
||||||
|
5. De gate-fix uit FR-24
|
||||||
|
|
||||||
|
Punt 1 gaat over serveren; punt 3 en 4 over dataduurzaamheid; punt 2 en 5 raken dezelfde middleware.
|
||||||
|
|
||||||
|
A) Laat unit 2 heel — alles moet toch vóór de eerste deploy klaar zijn, en opsplitsen levert units op die je nooit los oplevert
|
||||||
|
B) Splits in twee: **2a Hosting & Serving** (`wwwroot/web`, SPA-fallbacks, `/health`, gate-fix) en **2b Data Durability** (auto-migratie, key ring, `AddDataProtection`-conflict, discriminator)
|
||||||
|
C) Splits in drie: serveren, health/gate, dataduurzaamheid
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 4 — Volgorde: strikt serieel of waar mogelijk parallel?
|
||||||
|
|
||||||
|
**Context**: sommige units hebben een echte afhankelijkheid (unit 3's CSP heeft de origins uit unit 4 nodig; unit 6 heeft alles nodig). Andere niet: unit 1 en unit 7 staan vrijwel los.
|
||||||
|
|
||||||
|
Omdat elke unit een eigen goedkeuringsmoment heeft, is "parallel" hier vooral: mag ik in één ronde meerdere units afronden?
|
||||||
|
|
||||||
|
A) Strikt serieel — één unit per keer, elk met een eigen goedkeuring. Meeste controle, meeste rondes
|
||||||
|
B) Serieel waar afhankelijk, gegroepeerd waar onafhankelijk — bijvoorbeeld unit 1 en 2 in één ronde, en 5 en 6 in één ronde
|
||||||
|
C) Groepeer per laag: eerst alle applicatiewijzigingen (1–4), dan alle workflow-werk (5–6), dan documentatie (7) — drie rondes
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 5 — Wat betekent "klaar" per unit?
|
||||||
|
|
||||||
|
**Context**: de workflow bouwt en test elke unit automatisch vóór afronding. De vraag is hoe streng dat is.
|
||||||
|
|
||||||
|
A) Bouwt en alle bestaande tests slagen — nieuwe tests alleen waar de unit nieuw gedrag toevoegt dat te testen valt
|
||||||
|
B) Zoals A, plus verplicht nieuwe tests voor elk nieuw gedrag, ook als dat een registratietest is
|
||||||
|
C) Zoals B, plus een coverage-drempel per unit (let op: bij vraag 9 van de requirements koos je géén coverage-gate)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 6 — Commit- en reviewstrategie
|
||||||
|
|
||||||
|
**Context**: we werken op `feature/gitea-deployment-workflow`. Er is nog niets gecommit — alle aidlc-documentatie tot nu toe staat als werkmap-wijziging klaar.
|
||||||
|
|
||||||
|
A) Eén commit per unit, alles op deze ene branch, één pull request aan het eind
|
||||||
|
B) Eén commit per unit, en een pull request per unit — kleinere reviews, maar meer PR's
|
||||||
|
C) Vrij committen tijdens het werk, één samengevoegde commit per unit aan het eind (squash)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 7 — Er zijn geen user stories; waar mapt de story map naar?
|
||||||
|
|
||||||
|
**Context**: de User Stories-fase is overgeslagen (infrastructuurwerk zonder eindgebruikersfunctionaliteit). Het artefact `unit-of-work-story-map.md` is verplicht, maar er zijn geen stories om te mappen.
|
||||||
|
|
||||||
|
A) Map de 24 functionele requirements (FR-01…FR-24) naar units — dat is hier het equivalent van stories en geeft volledige dekkingscontrole
|
||||||
|
B) Map zowel de requirements als de 16 ontwerpcomponenten (C-01…C-16) naar units — dubbele controle op volledigheid
|
||||||
|
C) Genereer het bestand met een notitie dat het niet van toepassing is
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Question 8 — Hoort documentatie (unit 7) hier of in de Operations-fase?
|
||||||
|
|
||||||
|
**Context**: unit 7 bevat het website-workspace-contract (FR-09), README-updates en `.env.example`. De Operations-fase levert daarnaast al deployment-instructies, rollback-plan, monitoring-setup en de readiness-checklist op.
|
||||||
|
|
||||||
|
A) Unit 7 blijft in Construction voor repo-documentatie (README, `.env.example`, website-contract); Operations levert de operationele documenten — duidelijke scheiding tussen "wat in de repo hoort" en "hoe je het draait"
|
||||||
|
B) Verplaats alles naar de Operations-fase — één plek voor alle documentatie
|
||||||
|
C) Unit 7 vervalt; verdeel de documentatie over de units die de wijziging maken (README-stuk over `wwwroot` bij unit 2, enzovoort)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
# Requirements Clarification Questions — Ronde 2
|
||||||
|
|
||||||
|
Je antwoorden waren grotendeels eenduidig. Vier punten hebben opheldering nodig: één directe vraag van jou, twee combinaties van antwoorden die technisch niet samen kunnen, en één gevolg van je keuze voor blokkerende beveiligingsregels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SFTP is niet FTPS — jouw vraag beantwoord
|
||||||
|
|
||||||
|
Je vroeg bij Question 1: *"Op de pi kan ik met FileZilla ook verbinden met SFTP. Is dat voldoende om FTP als uitgangspunt te nemen?"*
|
||||||
|
|
||||||
|
**Nee, en gelukkig maakt dat je keuze eenvoudiger.** Ondanks de vergelijkbare namen zijn het losstaande protocollen:
|
||||||
|
|
||||||
|
- **SFTP** = SSH File Transfer Protocol. Draait volledig *binnen* een SSH-verbinding op poort 22. Dat FileZilla via SFTP verbindt, betekent dus dat **SSH werkt op de Pi** — en `scp` gebruikt exact dezelfde SSH-verbinding. De referentie-aanpak werkt daar dus zonder aanpassing.
|
||||||
|
- **FTPS** = FTP met TLS-versleuteling. Een compleet ander protocol op andere poorten, met een eigen server (vsftpd, IIS FTP). Dat SFTP werkt zegt hier **niets** over.
|
||||||
|
|
||||||
|
Kortom: voor de Pi is SSH/SCP het juiste antwoord, niet FTPS. FTPS is alleen relevant als productie op shared hosting komt, want daar is SSH doorgaans afwezig.
|
||||||
|
|
||||||
|
Daarmee blijft de echte vraag open: **jouw antwoord op Question 1 ging over de Pi, maar de vraag ging over productie.** Question 2 zegt dat de *test*-omgeving op de Pi draait. De commit-message van `3885703` noemt shared hosting (mijnhostingpartner.nl) als reden voor de single-host-opzet.
|
||||||
|
|
||||||
|
### Clarification Question 1
|
||||||
|
Waar draait de **productie**-omgeving van deze CMS?
|
||||||
|
|
||||||
|
A) Ook op je eigen Pi-infrastructuur — dan is SSH/SCP goed voor beide omgevingen en is FTPS voorlopig nergens nodig
|
||||||
|
B) Op shared hosting (mijnhostingpartner.nl) — dan bouwen we test via SSH/SCP naar de Pi én productie via FTPS, twee verschillende transportmechanismen in dezelfde workflow
|
||||||
|
C) Voorlopig op de Pi, maar shared hosting is het einddoel — bouw nu SSH/SCP en zet het transport zo op dat FTPS er later naast kan zonder de workflow te herstructureren
|
||||||
|
D) Nog onbekend — bouw alleen de test-deploy (SSH/SCP) en documenteer productie als open punt
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Één bundel versus omgevingstags — die twee kunnen niet samen
|
||||||
|
|
||||||
|
Bij Question 11 gaf je aan dat same-origin mag, dat lokaal een expliciete URL moet blijven werken, en dat de URL-variabele geen enkele bundel in de weg hoeft te staan. Dat kan ik prima bouwen (same-origin standaard + optionele override).
|
||||||
|
|
||||||
|
**Maar je andere antwoorden maken één bundel alsnog onmogelijk:**
|
||||||
|
|
||||||
|
- Question 14/15 = Sentry in de admin-SPA, met een `environment`-tag om test en productie te scheiden
|
||||||
|
- Question 20 = Umami ook op de admin-SPA, en Umami gebruikt per omgeving een **eigen website-ID**
|
||||||
|
|
||||||
|
Vite-variabelen worden op **build-time** in de bundel gebakken. Eén `dist/` kan dus niet tegelijk `environment: test` en `environment: production` zijn, en niet twee verschillende Umami-website-ID's bevatten. Dit is exact de reden dat je referentie-workflow een aparte `build-production`-job heeft.
|
||||||
|
|
||||||
|
### Clarification Question 2
|
||||||
|
Hoe lossen we dit op?
|
||||||
|
|
||||||
|
A) Accepteer twee builds, precies zoals de referentie — een test-build en een productie-build met eigen Vite-variabelen. Same-origin voor de API-URL blijft alsnog nuttig (minder configuratie, geen fout mogelijk), maar levert geen enkele bundel op
|
||||||
|
B) Maak de omgevingsconfiguratie runtime in plaats van build-time — de API levert Sentry-DSN, environment en Umami-website-ID uit zijn eigen configuratie, en de SPA haalt dat bij het opstarten op. Eén bundel geldig voor alle omgevingen, maar wel nieuw werk (endpoint + laadmoment in de SPA)
|
||||||
|
C) Twee builds nu (optie A), met runtime-configuratie (optie B) als later te overwegen verbetering
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `app_offline.htm` werkt niet op de Pi
|
||||||
|
|
||||||
|
Bij Question 22 koos je `app_offline.htm` vóór de upload plaatsen en erna weghalen. Dat is een prima techniek — maar het is een functie van de **ASP.NET Core Module in IIS**: IIS ziet dat bestand, stopt de app en serveert het als reactie op elk verzoek.
|
||||||
|
|
||||||
|
Op de Pi (Question 2 = B) draait je app als Kestrel-proces, waarschijnlijk achter nginx en beheerd door systemd. Daar heeft `app_offline.htm` **geen enkel effect** — het bestand wordt gewoon genegeerd, en de upload overschrijft DLL's van een draaiend proces, wat op Linux tot halve of vastgelopen requests leidt.
|
||||||
|
|
||||||
|
Er is bovendien een tweede reden waarom dit aandacht nodig heeft: de publieke website in `wwwroot/web/` wordt statisch geserveerd. Bij een nette stop is die dus óók onbereikbaar tijdens de deploy, terwijl die website niets met de CMS-deploy te maken heeft.
|
||||||
|
|
||||||
|
### Clarification Question 3
|
||||||
|
Hoe regelen we downtime-beheersing per omgeving?
|
||||||
|
|
||||||
|
A) Per omgeving de passende techniek: op de Pi het systemd-proces stoppen vóór de upload en erna starten; op IIS-hosting `app_offline.htm`. De workflow kiest op basis van de omgeving
|
||||||
|
B) Alleen de Pi-aanpak nu (proces stoppen/starten), en `app_offline.htm` toevoegen zodra er daadwerkelijk IIS-hosting is
|
||||||
|
C) Deploy naar een tijdelijke map en wissel dan van map (atomaire switch) — kortste downtime, en de publieke website blijft continu bereikbaar
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Blokkerende beveiligingsregels raken meer dan de workflow
|
||||||
|
|
||||||
|
Je koos voor het afdwingen van alle beveiligingsregels als **blokkerende** vereisten. Voor de pipeline zelf sluit dat mooi aan op je andere keuzes (vulnerability-gate blokkerend, lockfile aanwezig, geen hardcoded secrets, rate limiting bestaat al).
|
||||||
|
|
||||||
|
Twee regels vragen echter werk in de applicatie dat nu volledig ontbreekt, en ze zijn blokkerend — dus ik moet weten of je ze binnen deze feature wilt of expliciet wilt uitstellen:
|
||||||
|
|
||||||
|
- **SECURITY-04 — HTTP-securityheaders.** De app zet er nu geen enkele: geen `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options`, `X-Frame-Options` of `Referrer-Policy`. Normaal regel je die in nginx of IIS, maar jouw uitgangspunt is juist dat serverconfiguratie niet mogelijk is — dus horen ze in middleware in de app thuis. Let op: een CSP raakt ook de publieke website die uit een andere workspace komt, want die wordt door hetzelfde proces geserveerd. Umami en Sentry hebben daarnaast expliciete CSP-uitzonderingen nodig.
|
||||||
|
- **SECURITY-14 — alerting en logretentie.** Vereist alerting op authenticatiefouten en autorisatieschendingen, plus minimaal 90 dagen logretentie. Question 16 koos structured logging naar Sentry; Sentry's gratis plan bewaart events standaard **30 dagen**, dus 90 dagen is daarmee niet haalbaar zonder een betaald plan of een tweede bestemming.
|
||||||
|
|
||||||
|
### Clarification Question 4
|
||||||
|
Wat doen we met deze twee regels?
|
||||||
|
|
||||||
|
A) Beide binnen deze feature: securityheaders-middleware bouwen (inclusief CSP-uitzonderingen voor Umami/Sentry), en alerting via Sentry-alertregels — met de logretentie gedocumenteerd als bewuste afwijking op wat Sentry's plan biedt
|
||||||
|
B) Alleen SECURITY-04 (securityheaders) binnen deze feature; SECURITY-14 vastleggen als gedocumenteerde afwijking, omdat volledige alerting en 90-daagse retentie een aparte beslissing over kosten is
|
||||||
|
C) Beide vastleggen als gedocumenteerde afwijking en later oppakken — deze feature blijft strikt de deployment-workflow plus health check
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Clarification Question 5
|
||||||
|
Als er securityheaders komen (Question 4 = A of B): hoe strikt mag de `Content-Security-Policy` zijn, gegeven dat hetzelfde proces een publieke website serveert die jij niet in deze repo beheert?
|
||||||
|
|
||||||
|
A) Strikt (`default-src 'self'` plus expliciete uitzonderingen voor Umami en Sentry) en de website-instructies leggen vast waar een website-workspace zich aan moet houden
|
||||||
|
B) Strikt voor `/admin` en `/api`, ruimer voor de publieke website — een website-bouwer wordt dan niet beperkt door een CSP die hij niet kent
|
||||||
|
C) Alleen rapporterend beginnen (`Content-Security-Policy-Report-Only`) zodat niets stilletjes breekt, en later afdwingen
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
# Requirements Clarification Questions — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past en beschrijf dan je voorkeur.
|
||||||
|
|
||||||
|
Waar ik iets al uit de code of uit je SlpSoftware-workflow kon opmaken, staat dat als context boven de vraag — dan hoef je alleen te bevestigen of te corrigeren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Hosting en transport
|
||||||
|
|
||||||
|
### Question 1
|
||||||
|
**Context**: de referentie-workflow uploadt via `scp` met `sshpass` naar een Raspberry Pi. Shared hosting zoals mijnhostingpartner.nl biedt doorgaans geen SSH, maar wel FTP/FTPS en soms Web Deploy (msdeploy). Dit bepaalt de hele deploy-stap.
|
||||||
|
|
||||||
|
Hoe komt de gepubliceerde .NET-applicatie op de **productie**-host terecht?
|
||||||
|
|
||||||
|
A) FTPS — FTP over TLS, meestal standaard beschikbaar op shared hosting
|
||||||
|
B) Web Deploy / msdeploy — de MSBuild-native manier voor IIS-hosting, ondersteunt `app_offline` en incrementele sync
|
||||||
|
C) SSH/SCP — zoals de referentie, alleen als de host SSH aanbiedt
|
||||||
|
D) Ik weet nog niet wat de host ondersteunt — neem FTPS als uitgangspunt en documenteer hoe je overstapt
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: X, Op de pi kan ik met FileZilla ook verbinden met SFTP. Is dat voldoende om FTP als uitgangspunt te nemen? Anders beginnen met opzetten zoals de referentie met SSH/SCP en later aanpassen naar FTPS als dat nodig is.
|
||||||
|
|
||||||
|
### Question 2
|
||||||
|
**Context**: je hebt lokaal, test en productie. De referentie draait test én productie op dezelfde Raspberry Pi's.
|
||||||
|
|
||||||
|
Waar draait de **testomgeving** van de CMS?
|
||||||
|
|
||||||
|
A) Op dezelfde shared host als productie, als een tweede site/subdomein (bijv. `test.<domein>`)
|
||||||
|
B) Op je eigen Raspberry Pi-infrastructuur (zoals de referentie), met een .NET runtime erop
|
||||||
|
C) Op een andere shared-hostingaccount of -pakket
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
|
|
||||||
|
### Question 3
|
||||||
|
**Context**: de referentie gebruikt een zelf-gehoste Gitea Actions runner op Podman, waarbij container-based actions (`appleboy/scp-action`) faalden met een 409-fout — vandaar shell-stappen. De publish van deze CMS heeft bovendien **zowel de .NET 10 SDK als Node + pnpm** nodig.
|
||||||
|
|
||||||
|
Op welke runner draait deze workflow?
|
||||||
|
|
||||||
|
A) Dezelfde zelf-gehoste Podman-runner als de SlpSoftware-workflow — ik zorg dat .NET 10 SDK en Node/pnpm beschikbaar zijn (of laat de workflow ze installeren)
|
||||||
|
B) Dezelfde runner, maar installeer de toolchain expliciet in de workflow met `actions/setup-dotnet` en `pnpm/action-setup`
|
||||||
|
C) Een nieuwe/aparte runner speciaal voor .NET-builds
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. De publieke website in `wwwroot`
|
||||||
|
|
||||||
|
### Question 4
|
||||||
|
**Belangrijk risico dat ik in de code vond**: de publieke website van de klant staat in `wwwroot/`, en de admin-SPA in `wwwroot/admin/`. Een deploy die de hele `wwwroot` overschrijft of spiegelt, **wist daarmee de website van de klant** (die immers uit een andere workspace komt en niet in deze repo zit).
|
||||||
|
|
||||||
|
Hoe moet de deploy hiermee omgaan?
|
||||||
|
|
||||||
|
A) De CMS-deploy raakt `wwwroot/` nooit behalve `wwwroot/admin/` — de rest blijft staan, en de website-workspace deployt onafhankelijk zijn eigen bestanden in `wwwroot/`
|
||||||
|
B) De CMS-deploy overschrijft alles behalve een expliciete uitsluitingslijst, die ik in de workflow configureer
|
||||||
|
C) De publieke website komt in een aparte submap (bijv. `wwwroot/site/`) zodat de scheiding fysiek duidelijker is — vereist een kleine wijziging in `Program.cs`
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: C, noem de map "web" in plaats van "site", Dus dan krijgen we uiteindelijk 3 mappen in wwwroot: admin, web en eventueel een map voor de API (indien nodig).
|
||||||
|
|
||||||
|
### Question 5
|
||||||
|
Wat moeten de instructies voor een website-workspace precies vastleggen? (Het bouwen/deployen van de website zelf blijft buiten scope — dit gaat om het contract waaraan zo'n workspace zich moet houden.)
|
||||||
|
|
||||||
|
A) Alleen het doelpad en de mapstructuur — waar de build-output heen moet en welke paden verboden zijn (`admin/`)
|
||||||
|
B) Doelpad plus technische randvoorwaarden — routing/SPA-fallback-gedrag, verboden bestandsnamen, hoe je `/api/v1` vanaf de website aanroept
|
||||||
|
C) Doelpad, randvoorwaarden én een voorbeeld-deploystap (een YAML-snippet die een website-workspace kan overnemen in zijn eigen workflow)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Workflow-opzet en gates
|
||||||
|
|
||||||
|
### Question 6
|
||||||
|
**Context**: in de referentie draait de testdeploy automatisch bij een push/merge naar `master`, en productie alleen bij een handmatige `workflow_dispatch` met een `deploy_production`-vinkje — bewust, zodat niemand per ongeluk productie deployt.
|
||||||
|
|
||||||
|
Wil je diezelfde triggerstrategie hier?
|
||||||
|
|
||||||
|
A) Ja, identiek aan de referentie — PR's valideren, `master` deployt naar test, productie alleen expliciet via `workflow_dispatch`
|
||||||
|
B) Ja, maar productie moet ook een handmatige goedkeuringsstap of aparte bevestiging hebben bovenop het vinkje
|
||||||
|
C) Productie mag automatisch bij een tag/release (bijv. `v1.2.3`) in plaats van via een vinkje
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
### Question 7
|
||||||
|
**Gemeten feit**: `pnpm run lint` faalt nu met 5 errors en 1 warning (in `AddCmsInstanceDialog.tsx`, `InviteUserDialog.tsx` 2×, `SettingsPage.tsx`, `SetStatusDialog.tsx`). Alle 219 backend- en 213 frontend-tests slagen wél. Een lint-gate wordt dus meteen rood.
|
||||||
|
|
||||||
|
Hoe gaan we hiermee om?
|
||||||
|
|
||||||
|
A) Los de 5 lint-errors op als onderdeel van deze feature, en zet de lint-gate daarna blokkerend
|
||||||
|
B) Zet de lint-gate blokkerend en laat de fixes over aan `tech-debt-backlog` — de workflow is dan pas groen ná die feature
|
||||||
|
C) Neem lint op als niet-blokkerende stap (rapporteert, faalt de build niet) en maak hem later blokkerend
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 8
|
||||||
|
**Gemeten feit**: `Microsoft.OpenApi` 2.0.0 en `System.Security.Cryptography.Xml` 10.0.9 hebben high-severity advisories; beide komen transitief binnen.
|
||||||
|
|
||||||
|
Wil je een vulnerability-gate in de workflow?
|
||||||
|
|
||||||
|
A) Ja, blokkerend — en we pinnen de twee packages naar gepatchte versies als onderdeel van deze feature
|
||||||
|
B) Ja, maar niet-blokkerend (rapporteert alleen), zodat de deploy niet vastloopt op transitieve advisories
|
||||||
|
C) Nee, geen vulnerability-scan in deze workflow
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 9
|
||||||
|
Welke gates moeten er in de pipeline zitten vóór een deploy? (Meerdere letters mogen, bijv. `A, B, C`.)
|
||||||
|
|
||||||
|
A) Backend build (`dotnet build -c Release`)
|
||||||
|
B) Backend tests (`dotnet test`)
|
||||||
|
C) Frontend type-check + build (`tsc -b && vite build`)
|
||||||
|
D) Frontend tests (`vitest run`)
|
||||||
|
E) Frontend lint / format-check
|
||||||
|
F) Code coverage-drempel (nu is er geen enkele drempel afgedwongen)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A, B, C, D, E
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Database en configuratie
|
||||||
|
|
||||||
|
### Question 10
|
||||||
|
**Gemeten feit**: `AvailabilityDbContext` en `MasterDbContext` migreren zichzelf bij het opstarten, maar `ApplicationDbContext` (Identity) **nooit**. Een verse deploy start dus zonder Identity-tabellen tot iemand handmatig `dotnet ef database update` draait. Op shared hosting kun je vaak geen CLI-commando's op de server uitvoeren.
|
||||||
|
|
||||||
|
Hoe worden migraties in test en productie toegepast?
|
||||||
|
|
||||||
|
A) De pipeline genereert een idempotent SQL-script (`dotnet ef migrations script --idempotent`) als build-artifact, dat ik zelf uitvoer op de database — expliciet en controleerbaar
|
||||||
|
B) De pipeline past migraties direct toe op de database vanuit de runner (vereist dat de runner de database kan bereiken)
|
||||||
|
C) Laat `ApplicationDbContext` bij het opstarten automatisch migreren, net als de twee module-contexts — dan is deploy self-contained
|
||||||
|
D) Combinatie: automatisch bij opstarten voor test, idempotent script voor productie
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: C
|
||||||
|
|
||||||
|
### Question 11
|
||||||
|
**Gemeten feit**: de admin-SPA eist `VITE_API_BASE_URL` als **absolute** URL (Zod-gevalideerd in `config.ts`). In het single-host model staat de API echter op dezelfde origin als `/admin`, dus dit is technisch onnodig — maar zolang het zo is, moet je per omgeving een aparte bundel bouwen.
|
||||||
|
|
||||||
|
Wat doen we hiermee?
|
||||||
|
|
||||||
|
A) Pas `config.ts` aan zodat de API-basis-URL leeg/relatief mag zijn en standaard same-origin is — dan is één bundel geldig voor test én productie
|
||||||
|
B) Laat het zoals het is en bouw twee keer (aparte test- en productie-build), zoals de referentie doet voor `VITE_APP_ENV`
|
||||||
|
C) Pas het aan naar same-origin, maar houd een optionele override-variabele voor uitzonderingsgevallen
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: X, Het mag naar same-origin, maar de frontend kan ook een aparte test-build hebben net als de referentie website. Ook is het zo dat nu lokaal wel een url moet worden opgegeven. Als dat verder ook blijft werken hoeft de url variabele niet blokkerend te zijn voor een enkele bundel
|
||||||
|
|
||||||
|
### Question 12
|
||||||
|
**Gemeten feit**: er is geen `appsettings.Test.json` en geen `ASPNETCORE_ENVIRONMENT`-waarde voor test. Productiegeheimen worden verwacht als environment-variabelen (`ConnectionStrings__DefaultConnection`, `JwtSettings__Secret`, etc.).
|
||||||
|
|
||||||
|
Hoe wordt de test/productie-configuratie geleverd?
|
||||||
|
|
||||||
|
A) Environment-variabelen op de host, per omgeving handmatig ingesteld — de workflow raakt ze niet
|
||||||
|
B) De workflow schrijft ze bij deploy in een bestand (bijv. `appsettings.Production.json`) op basis van Gitea Secrets
|
||||||
|
C) Environment-variabelen op de host voor geheimen, plus een nieuw `appsettings.Test.json` in de repo voor niet-geheime test-instellingen
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 13
|
||||||
|
**Gemeten feit**: Data Protection gebruikt de standaard bestandssysteem-key-ring zonder persistente store. Een redeploy die die map wist, maakt opgeslagen slave API keys **onleesbaar** — master↔slave-communicatie stopt dan tot instanties opnieuw worden toegevoegd. Dit staat al als waarschuwing in de README.
|
||||||
|
|
||||||
|
Nemen we dit mee in deze feature?
|
||||||
|
|
||||||
|
A) Ja — configureer een persistente key ring (`PersistKeysToDbContext`, gebruikt de bestaande database) zodat een redeploy veilig is
|
||||||
|
B) Ja, maar alleen documenteren welke map bij een deploy niet overschreven mag worden — geen codewijziging
|
||||||
|
C) Nee, buiten scope — apart oppakken
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Monitoring en observability
|
||||||
|
|
||||||
|
### Question 14
|
||||||
|
**Context**: je gebruikt Sentry plus console-logging. De referentie heeft alleen een frontend (`@sentry/react`); hier is er ook een .NET-backend.
|
||||||
|
|
||||||
|
Waar komt Sentry?
|
||||||
|
|
||||||
|
A) Beide — `Sentry.AspNetCore` in de API én `@sentry/react` in de admin-SPA
|
||||||
|
B) Alleen de backend — de admin-SPA is intern gebruik, daar volstaat console-logging
|
||||||
|
C) Alleen de frontend, net als de referentie
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 15
|
||||||
|
**Context**: de referentie gebruikt één Sentry-project voor beide omgevingen en onderscheidt ze met een `environment`-tag.
|
||||||
|
|
||||||
|
Hoe richten we Sentry-projecten in voor de CMS?
|
||||||
|
|
||||||
|
A) Eén Sentry-project voor de CMS, met `environment`-tags voor test en productie (zoals de referentie)
|
||||||
|
B) Aparte Sentry-projecten per omgeving
|
||||||
|
C) Aparte Sentry-projecten voor backend en frontend, elk met environment-tags
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 16
|
||||||
|
**Gemeten feit**: het productie-loglevel staat op `Warning` en er is alleen de console-logger. Op shared hosting is een console vaak niet zichtbaar — logs zijn dan effectief nergens.
|
||||||
|
|
||||||
|
Wat is de logging-opzet in productie?
|
||||||
|
|
||||||
|
A) Console-logging blijft (voor lokaal/test) en Sentry vangt fouten op — dat is voldoende
|
||||||
|
B) Console plus een logbestand op de host, met dagelijkse rotatie
|
||||||
|
C) Console plus structured logging naar Sentry inclusief informatievere niveaus dan alleen exceptions
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
### Question 17
|
||||||
|
**Context**: `AddHealthChecks()` + `MapHealthChecks("/health")` kost geen package; `.AddDbContextCheck<ApplicationDbContext>()` kost er één. `/health` moet op de bypass-lijst van `AvailabilityMiddleware`, anders geeft de gate een 503 als een instantie is uitgezet.
|
||||||
|
|
||||||
|
Wat moet de health check controleren?
|
||||||
|
|
||||||
|
A) Alleen liveness — draait het proces? Geen database-aanroep, snelste en meest stabiele signaal voor UptimeRobot
|
||||||
|
B) Liveness plus database-connectiviteit (`AddDbContextCheck`)
|
||||||
|
C) Liveness, database én openstaande migraties — dan zie je ook een half-gedeployde staat
|
||||||
|
D) Twee endpoints: `/health` voor liveness (voor UptimeRobot) en `/health/ready` met database en migraties (voor jezelf)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Question 18
|
||||||
|
Wat moet UptimeRobot monitoren?
|
||||||
|
|
||||||
|
A) Alleen `/health` per omgeving
|
||||||
|
B) `/health` plus de publieke website (`/`) — die wordt namelijk buiten de availability-gate om geserveerd
|
||||||
|
C) `/health`, de publieke website en `/admin`
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
### Question 19
|
||||||
|
**Context**: de referentie heeft een zelf-gehoste Umami op `analytics.slpsoftware.nl` (Podman op de Pi), met per omgeving een eigen website-ID.
|
||||||
|
|
||||||
|
Hoe gebruiken we Umami hier?
|
||||||
|
|
||||||
|
A) Hergebruik de bestaande zelf-gehoste Umami — voeg alleen nieuwe website-entries toe voor de CMS-omgevingen
|
||||||
|
B) Nieuwe, aparte Umami-instantie voor de CMS
|
||||||
|
C) Hergebruik de bestaande instantie, maar dit is per-klant: elke CMS-installatie krijgt zijn eigen website-ID
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]: A
|
||||||
|
|
||||||
|
### Question 20
|
||||||
|
**Context**: de admin-SPA is een intern beheerscherm; de publieke website komt uit een andere workspace.
|
||||||
|
|
||||||
|
Wat wordt er met Umami gemeten?
|
||||||
|
|
||||||
|
A) Alleen de publieke website — en de instructies leggen vast hoe een website-workspace het script meekrijgt
|
||||||
|
B) Publieke website plus de admin-SPA (om te zien hoe beheerders het CMS gebruiken)
|
||||||
|
C) Alleen de admin-SPA — de publieke website regelt zijn eigen analytics volledig zelf
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:B
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Rollback en betrouwbaarheid
|
||||||
|
|
||||||
|
### Question 21
|
||||||
|
**Context**: de referentie rolt terug door een eerdere commit opnieuw te bouwen en te uploaden. Bij een .NET-app met database-migraties is dat riskanter, omdat een migratie niet zomaar terugdraait.
|
||||||
|
|
||||||
|
Wat is de rollback-strategie?
|
||||||
|
|
||||||
|
A) Opnieuw bouwen en deployen van een eerdere commit, en migraties bewust voorwaarts-compatibel houden (nooit destructief)
|
||||||
|
B) Zoals A, plus de vorige publish-output bewaren op de host zodat je snel kunt terugzetten zonder te bouwen
|
||||||
|
C) Zoals B, plus een database-backup vóór elke productie-deploy
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
### Question 22
|
||||||
|
**Context**: op IIS-hosting kun je met een `app_offline.htm`-bestand de app netjes stilleggen tijdens een deploy — zonder serverconfiguratie. Zonder dat kunnen bezoekers halve bestanden of vergrendelde DLL's tegenkomen.
|
||||||
|
|
||||||
|
Wil je downtime-beheersing tijdens de deploy?
|
||||||
|
|
||||||
|
A) Ja — plaats `app_offline.htm` vóór de upload en verwijder het erna
|
||||||
|
B) Nee, korte downtime tijdens de upload is acceptabel
|
||||||
|
C) Ja, en gebruik daarnaast de bestaande availability-functionaliteit om de instantie in onderhoudsmodus te zetten
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Workflow-extensies en fasering
|
||||||
|
|
||||||
|
### Vraag: Beveiligingsextensies
|
||||||
|
Moeten de beveiligingsregels als harde vereisten worden afgedwongen voor dit project?
|
||||||
|
|
||||||
|
A) Ja — dwing alle BEVEILIGINGSREGELS af als blokkerende vereisten (aanbevolen voor productietoepassingen)
|
||||||
|
B) Nee — sla alle BEVEILIGINGSREGELS over (geschikt voor PoC's, prototypes en experimentele projecten)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
|
|
||||||
|
### Vraag: Property-Based Testing Extensie
|
||||||
|
Moeten de property-based testing (PBT) regels worden afgedwongen voor dit project?
|
||||||
|
|
||||||
|
A) Ja — dwing alle PBT-regels af als blokkerende vereisten (aanbevolen voor projecten met bedrijfslogica, datatransformaties, serialisatie of stateful componenten)
|
||||||
|
B) Gedeeltelijk — dwing PBT-regels alleen af voor pure functies en serialisatie round-trips (geschikt voor projecten met beperkte algoritmische complexiteit)
|
||||||
|
C) Nee — sla alle PBT-regels over (geschikt voor eenvoudige CRUD-applicaties, UI-only projecten of dunne integratielagen zonder significante bedrijfslogica)
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:C
|
||||||
|
|
||||||
|
### Vraag: Operations-fase
|
||||||
|
Moet deze feature na Construction door de Operations-fase (deployment- en monitoring-opzet)?
|
||||||
|
|
||||||
|
**Toelichting**: voor deze feature is dat vermoedelijk het zwaartepunt — de deployment-instructies, monitoring-opzet en productie-readiness-checklist horen daar thuis.
|
||||||
|
|
||||||
|
A) Ja — draai de Operations-fase na Construction (deployment + monitoring setup)
|
||||||
|
B) Nee — stop na Build and Test (deployment/monitoring vallen buiten scope voor deze feature)
|
||||||
|
C) Nog niet zeker — vraag het me opnieuw na de Construction-fase
|
||||||
|
X) Anders (beschrijf hieronder na de [Answer]:-tag)
|
||||||
|
|
||||||
|
[Answer]:A
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# Requirements — Gitea Deployment Workflow
|
||||||
|
|
||||||
|
**Feature**: `gitea-deployment-workflow`
|
||||||
|
**Branch**: `feature/gitea-deployment-workflow`
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
**Requirements depth**: Comprehensive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Intent Analysis
|
||||||
|
|
||||||
|
### User Request (verbatim, as logged in `audit.md`)
|
||||||
|
> "Ik wil een Gitea workflow gaan opzetten om de CMS te kunnen deployen. In de instructies moet ook behandeld worden waar de frontend van de website moet gaan komen, maar de workflow voor de website zelf zal hierin niet worden gebouwd, dat wordt per website in hun eigen workspace gedaan. Gebruik de aidlc workflow om alles op te zetten. Voor Uptime gebruik ik UptimeRobot, voor analytics gebruik ik Umami en voor logging console logs samen met Sentry. Ik heb in de workspace `K:\Development\SlpSoftware\Projects\SlpSoftware` al een werkende workflow. Dit is voor een react vite front-end, maar is een goed startpunt voor deze workspace. Op dit moment zijn er alleen een lokale omgeving, test en productie. Het doel van deze workflow en eventuele deployment-opzet is dat we boel moeten kunnen uploaden als een .NET-applicatie en dat we geen server-configuratie hoeven te doen omdat dat voor andere websites waarschijnlijk niet kan. Vandaar dat ik een opzet wilde met de api en de 2 frontends als 1 website."
|
||||||
|
|
||||||
|
### Analysis
|
||||||
|
|
||||||
|
| Dimension | Assessment |
|
||||||
|
|---|---|
|
||||||
|
| **Request Clarity** | Clear on goals and tooling; initially incomplete on hosting/transport, migration delivery and `wwwroot` ownership. All resolved across two clarification rounds. |
|
||||||
|
| **Request Type** | New Feature (CI/CD + operational tooling), with supporting Enhancement work inside the application. |
|
||||||
|
| **Scope Estimate** | Multiple Components — new `.gitea/workflows/`, changes to `SlpModularCms.Api` (`Program.cs`, `.csproj`), `SlpModularCms.Core` (security headers, Data Protection, health checks), `SlpModularCms.Modules.Availability` (bypass list), `frontend/` (config, Sentry, Umami, lint fixes), plus documentation and operations artifacts. |
|
||||||
|
| **Complexity Estimate** | Moderate-to-Complex. Not algorithmically hard; the difficulty is the constraint set — one process serving three surfaces, a customer-owned `wwwroot` that must survive deploys, no server configuration permitted, two different hosting targets, asymmetric migration behaviour, and build-time environment coupling in the SPA. |
|
||||||
|
| **Primary Business Driver** | Make deployment of the CMS repeatable and safe on hosting where nothing can be configured server-side, so the product can be shipped per customer without bespoke server work. |
|
||||||
|
|
||||||
|
### Core Constraint (drives most design decisions)
|
||||||
|
**No server configuration may be required.** This is why the API serves the public website, the admin SPA and the API from one process, and it is why anything normally solved in nginx or IIS configuration — security headers, routing, SPA fallbacks — must live inside the application instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- Gitea Actions CI workflow with build, test and lint gates for both backend and frontend.
|
||||||
|
- Gitea Actions deploy workflow: automated to test, explicit opt-in to production.
|
||||||
|
- `wwwroot` restructuring so the customer's public website can never be destroyed by a CMS deploy.
|
||||||
|
- A documented contract for what a per-website workspace must deliver.
|
||||||
|
- A dedicated health-check endpoint.
|
||||||
|
- Sentry (backend + frontend), Umami analytics, UptimeRobot monitoring.
|
||||||
|
- HTTP security-headers middleware.
|
||||||
|
- Persistent Data Protection key ring.
|
||||||
|
- Automatic `ApplicationDbContext` migration at startup.
|
||||||
|
- Same-origin default for the admin SPA's API base URL.
|
||||||
|
- Fixing the 5 existing frontend lint errors and pinning 2 vulnerable transitive packages.
|
||||||
|
- Rollback strategy including pre-deploy database backup.
|
||||||
|
|
||||||
|
### Explicitly Out of Scope
|
||||||
|
- **The public website's own build and deploy workflow.** Handled per website in its own workspace (user's explicit instruction). This feature delivers only the contract and the instructions.
|
||||||
|
- **Any fourth environment.** Only local, test and production exist.
|
||||||
|
- **Backend integration/contract/e2e tests.** Their absence is recorded in `code-quality-assessment.md` as existing technical debt; adding them is not part of this feature.
|
||||||
|
- **Code coverage thresholds** (Q9: F not selected).
|
||||||
|
- **Property-based testing** (extension opted out).
|
||||||
|
- **90-day log retention** — accepted as a documented deviation (see § 6, DEV-01).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decisions Register
|
||||||
|
|
||||||
|
Every decision below traces to a question answer. `Q*` = first round, `CQ*` = clarification round.
|
||||||
|
|
||||||
|
### Hosting and transport
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-01 | **Test** runs on the user's own Raspberry Pi infrastructure with a .NET runtime. | Q2 = B |
|
||||||
|
| D-02 | **Production** runs on the Pi for now, with shared hosting as the eventual target. The transport layer must be structured so FTPS can be added alongside SSH/SCP later **without restructuring the workflow**. | CQ1 = C |
|
||||||
|
| D-03 | Transport is **SSH/SCP** (or SFTP — same SSH transport). FTPS is not needed yet. Confirmed viable because FileZilla connects to the Pi over SFTP, which proves SSH is available. | Q1 = X + CQ1 = C |
|
||||||
|
| D-04 | Workflow runs on the **existing self-hosted Podman runner**, with the toolchain installed explicitly in the workflow via `actions/setup-dotnet` and `pnpm/action-setup` rather than assumed present. | Q3 = B |
|
||||||
|
| D-05 | Container-based actions must be avoided in favour of plain shell steps, because they fail on this runner with a 409 attach error. | Reference project's documented experience |
|
||||||
|
|
||||||
|
### `wwwroot` layout and the public website
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-06 | The public website moves into **`wwwroot/web/`** (user's chosen name, not `site`). Final layout: `wwwroot/admin/` (CMS admin SPA, shipped by this repo) and `wwwroot/web/` (customer website, deployed separately). Requires a change to `Program.cs`. | Q4 = C |
|
||||||
|
| D-07 | An additional `wwwroot` folder for the API is kept as an option but is **not required** — see ASM-02. | Q4 = C ("eventueel … indien nodig") |
|
||||||
|
| D-08 | The website-workspace contract documents the target path **plus technical constraints**: routing/SPA-fallback behaviour, forbidden paths and filenames, and how to call `/api/v1` from the website. A ready-made YAML deploy snippet is not required. | Q5 = B |
|
||||||
|
|
||||||
|
### Workflow shape and gates
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-09 | Trigger strategy is **identical to the reference**: pull requests validate; push/merge to `master` deploys to test; production only via manual `workflow_dispatch` with an explicit `deploy_production` checkbox. | Q6 = A |
|
||||||
|
| D-10 | Pipeline gates: backend build, backend tests, frontend type-check + build, frontend tests, frontend lint/format-check. **No coverage threshold.** | Q9 = A,B,C,D,E |
|
||||||
|
| D-11 | The 5 existing frontend lint errors are **fixed within this feature**, after which the lint gate is blocking. | Q7 = A |
|
||||||
|
| D-12 | A **blocking** vulnerability gate is added, and `Microsoft.OpenApi` and `System.Security.Cryptography.Xml` are pinned to patched versions within this feature. | Q8 = A |
|
||||||
|
|
||||||
|
### Database and configuration
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-13 | `ApplicationDbContext` migrates **automatically at startup**, matching the two module contexts. Deployment becomes self-contained; no CLI access to the host is needed. | Q10 = C |
|
||||||
|
| D-14 | The admin SPA defaults to **same-origin** for the API base URL, with an optional explicit override that must keep working for local development. | Q11 = X |
|
||||||
|
| D-15 | **Two builds** are produced (test and production) with their own Vite variables, exactly like the reference — because Sentry's `environment` tag and Umami's per-environment website ID are build-time values. Runtime-delivered configuration is recorded as a possible later improvement, not built now. | CQ2 = C |
|
||||||
|
| D-16 | Test and production configuration comes from **environment variables on the host**, set manually per environment. The workflow does not write or manage them. | Q12 = A |
|
||||||
|
| D-17 | Data Protection uses a **persistent key ring via `PersistKeysToDbContext`**, reusing the existing database, so a redeploy can never make stored slave API keys unreadable. | Q13 = A |
|
||||||
|
|
||||||
|
### Monitoring and observability
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-18 | Sentry on **both sides**: `Sentry.AspNetCore` in the API and `@sentry/react` in the admin SPA. | Q14 = A |
|
||||||
|
| D-19 | **One** Sentry project for the CMS, with `environment` tags distinguishing test from production. | Q15 = A |
|
||||||
|
| D-20 | Logging is console **plus structured logging to Sentry**, at levels more informative than exceptions alone. | Q16 = C |
|
||||||
|
| D-21 | Health check is **liveness only** — no database call. Fastest and most stable signal, and it must not go red for reasons unrelated to the process being alive. | Q17 = A |
|
||||||
|
| D-22 | `/health` must be added to `AvailabilityMiddleware._bypassPrefixes`, or the availability gate returns 503 for it on a disabled instance. | Established during Reverse Engineering |
|
||||||
|
| D-23 | UptimeRobot monitors **`/health`, the public website (`/`) and `/admin`** per environment. | Q18 = C |
|
||||||
|
| D-24 | **Reuse the existing self-hosted Umami** (`analytics.slpsoftware.nl`); add new website entries for the CMS environments. | Q19 = A |
|
||||||
|
| D-25 | Umami measures **both the public website and the admin SPA**. | Q20 = B |
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-26 | Rollback = rebuild and redeploy an earlier commit, **plus** retaining the previous publish output on the host for a fast restore without building, **plus** a database backup before every production deploy. Migrations must be kept forward-compatible and never destructive. | Q21 = C |
|
||||||
|
| D-27 | Downtime control uses an **atomic release-directory switch**: deploy into a new directory, then switch. Shortest downtime, and the public website stays continuously reachable. `app_offline.htm` is not used, since it is an IIS-only mechanism that has no effect on the Pi. | Q22 = A → corrected to CQ3 = C |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
| ID | Decision | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| D-28 | The Security Baseline extension is **enabled and blocking**. | Extension opt-in = A |
|
||||||
|
| D-29 | Property-Based Testing is **disabled**. | Extension opt-in = C |
|
||||||
|
| D-30 | **SECURITY-04 and SECURITY-14 are both addressed within this feature**: security-headers middleware (with CSP exceptions for Umami and Sentry) and alerting through Sentry alert rules, with log retention documented as a deliberate deviation. | CQ4 = A |
|
||||||
|
| D-31 | CSP is **strict for `/admin` and `/api/v1`, and more permissive for the public website**, so a website builder is not constrained by a policy they never see. | CQ5 = B |
|
||||||
|
| D-32 | The Operations phase **runs** after Construction. | Operations opt-in = A |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Functional Requirements
|
||||||
|
|
||||||
|
### FR-01 — Continuous integration workflow
|
||||||
|
A `.gitea/workflows/continuous_integration.yaml` MUST exist that triggers on:
|
||||||
|
- `pull_request` (opened, synchronize, reopened) — validation only, any branch
|
||||||
|
- `push` to `master` — validation plus test deploy
|
||||||
|
- `workflow_dispatch` — with a `deploy_production` boolean input, default `false`
|
||||||
|
|
||||||
|
It MUST run these gates, all blocking (D-10, D-11, D-12):
|
||||||
|
1. `dotnet build SlpModularCms.sln -c Release`
|
||||||
|
2. `dotnet test SlpModularCms.sln -c Release`
|
||||||
|
3. `dotnet list package --vulnerable --include-transitive` — fails the build on any advisory
|
||||||
|
4. `pnpm run build` in `frontend/` (includes `tsc -b`)
|
||||||
|
5. `pnpm test` in `frontend/`
|
||||||
|
6. `pnpm run lint` and `pnpm run format:check` in `frontend/`
|
||||||
|
|
||||||
|
Tool versions MUST be pinned (`actions/setup-dotnet`, `pnpm/action-setup`) and no action may rely on a `latest` tag (D-04, SECURITY-10).
|
||||||
|
|
||||||
|
### FR-02 — Reusable deploy workflow
|
||||||
|
A `.gitea/workflows/deploy.yaml` MUST exist as a `workflow_call` workflow accepting at minimum `artifact_name`, `environment` and `deploy_path`. It MUST use plain shell steps rather than container actions (D-05).
|
||||||
|
|
||||||
|
Its transport step MUST be structured so a second transport (FTPS) can be added later as an alternative without restructuring the workflow or its interface (D-02) — for example by taking the transport as an input with SSH/SCP as the only implemented value for now.
|
||||||
|
|
||||||
|
Environment-specific paths MUST come from Gitea Actions **variables** and credentials from **secrets**, never hardcoded, following the reference project's split.
|
||||||
|
|
||||||
|
### FR-03 — Test deployment
|
||||||
|
On push/merge to `master`, or on any `workflow_dispatch` run, the pipeline MUST deploy to the test environment on the Pi automatically (D-01, D-09).
|
||||||
|
|
||||||
|
### FR-04 — Production deployment
|
||||||
|
Production MUST deploy **only** on a `workflow_dispatch` run with `deploy_production = true`. Pushing to `master` MUST NOT be able to deploy production under any circumstance (D-09).
|
||||||
|
|
||||||
|
### FR-05 — Separate test and production builds
|
||||||
|
The pipeline MUST produce two distinct build artifacts, each built with its own environment-specific Vite variables (`VITE_APP_ENV`, `VITE_SENTRY_DSN`, `VITE_UMAMI_SCRIPT_URL`, `VITE_UMAMI_WEBSITE_ID_<ENV>`) so Sentry events and analytics are attributed to the correct environment (D-15).
|
||||||
|
|
||||||
|
### FR-06 — Atomic release switch
|
||||||
|
Deployment MUST place the new publish output in a fresh directory and then switch to it atomically, rather than overwriting a live directory (D-27). The application process MUST be restarted as part of the switch, since a running .NET process holds its assemblies.
|
||||||
|
|
||||||
|
The previous release directory MUST be retained on the host to allow a fast restore without rebuilding (D-26).
|
||||||
|
|
||||||
|
### FR-07 — `wwwroot` restructuring
|
||||||
|
`Program.cs` MUST serve the public website from `wwwroot/web/` at `/` instead of directly from `wwwroot/` (D-06). The existing behaviour MUST be preserved:
|
||||||
|
- `/` and non-file paths fall back to the public website's `index.html`
|
||||||
|
- `/admin` and `/admin/**` non-file paths fall back to `wwwroot/admin/index.html`
|
||||||
|
- Missing paths that look like files (with an extension) still return `404`
|
||||||
|
|
||||||
|
`frontend/vite.config.ts` keeps `base: '/admin/'` for builds; no change is needed there.
|
||||||
|
|
||||||
|
### FR-08 — The public website must survive every CMS deploy
|
||||||
|
No deployment step may delete, overwrite or mirror-sync the contents of `wwwroot/web/` (D-06). Because releases are switched atomically (FR-06), `wwwroot/web/` MUST live outside the swapped release directory and be linked or mounted into it — otherwise an atomic switch would silently discard the customer's website along with the old release. See ASM-01.
|
||||||
|
|
||||||
|
### FR-09 — Website workspace contract
|
||||||
|
Documentation MUST specify, for a per-website workspace (D-08):
|
||||||
|
- The exact target path (`wwwroot/web/`) and required structure, including `index.html`
|
||||||
|
- That `wwwroot/admin/` and the application root are forbidden targets
|
||||||
|
- How SPA-fallback routing behaves for the website, and which paths are reserved (`/admin`, `/api/v1`, `/health`)
|
||||||
|
- How to call `/api/v1` from the website — same-origin, so relative URLs work and no CORS configuration is needed
|
||||||
|
- Which CSP applies to the public website and what that permits (D-31)
|
||||||
|
- How to include the Umami tracking script (D-25)
|
||||||
|
|
||||||
|
### FR-10 — Health-check endpoint
|
||||||
|
The application MUST expose `GET /health` returning `200`/`Healthy` when the process is alive and `503`/`Unhealthy` otherwise, using `AddHealthChecks()` and `MapHealthChecks("/health")` (D-21). It MUST NOT perform a database call.
|
||||||
|
|
||||||
|
`/health` MUST be added to `AvailabilityMiddleware._bypassPrefixes` so the availability gate cannot mask it (D-22).
|
||||||
|
|
||||||
|
`/health` MUST NOT be presented as, or conflated with, `/api/v1/Availability/status` or `/api/v1/System/capabilities`, which are CMS domain functionality.
|
||||||
|
|
||||||
|
### FR-11 — Automatic Core migrations
|
||||||
|
`ApplicationDbContext` MUST be migrated automatically at application startup, consistent with `AvailabilityDbContext` and `MasterDbContext` (D-13), so a fresh deployment needs no CLI access to the host.
|
||||||
|
|
||||||
|
Migrations MUST be forward-compatible and non-destructive, so redeploying an earlier commit remains a valid rollback (D-26).
|
||||||
|
|
||||||
|
### FR-12 — Persistent Data Protection key ring
|
||||||
|
Data Protection MUST persist keys via `PersistKeysToDbContext` against the existing database (D-17), so redeploys and the atomic release switch cannot render stored slave API keys unreadable.
|
||||||
|
|
||||||
|
### FR-13 — Same-origin API base URL for the admin SPA
|
||||||
|
`frontend/src/lib/config.ts` MUST treat an absent or empty `VITE_API_BASE_URL` as same-origin, while continuing to accept an explicit absolute URL for local development against `https://localhost:7221` or `:7222` (D-14). Its Zod validation MUST be relaxed accordingly, without silently accepting malformed values.
|
||||||
|
|
||||||
|
### FR-14 — Sentry on the backend
|
||||||
|
`Sentry.AspNetCore` MUST be integrated into the API, reporting unhandled exceptions and structured logs at levels beyond exceptions alone (D-18, D-20), tagged with the environment (D-19). Sentry MUST be optional: an absent DSN disables it and leaves console logging active, mirroring the reference project's behaviour.
|
||||||
|
|
||||||
|
### FR-15 — Sentry in the admin SPA
|
||||||
|
`@sentry/react` MUST be integrated into the admin SPA with environment and release tags, and MUST be skipped gracefully when no DSN is configured (D-18, D-19).
|
||||||
|
|
||||||
|
### FR-16 — Umami analytics
|
||||||
|
The Umami tracking script MUST be included in the admin SPA, using per-environment website IDs from build-time variables, and MUST be absent during local development (D-24, D-25). Website entries are added to the existing self-hosted Umami instance. Inclusion for the public website is covered by FR-09.
|
||||||
|
|
||||||
|
### FR-17 — UptimeRobot monitors
|
||||||
|
Documentation MUST specify UptimeRobot monitors for `/health`, `/` and `/admin`, per environment (D-23), including what each one does and does not prove.
|
||||||
|
|
||||||
|
### FR-18 — HTTP security headers
|
||||||
|
Middleware MUST set, on all HTML-serving responses (D-30, SECURITY-04):
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` |
|
||||||
|
| `X-Frame-Options` | `DENY` |
|
||||||
|
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
||||||
|
| `Content-Security-Policy` | Path-scoped per D-31 |
|
||||||
|
|
||||||
|
CSP MUST be strict for `/admin` and `/api/v1` (`default-src 'self'` plus explicit allowances for the Umami script origin and Sentry's ingest endpoint) and more permissive for the public website (D-31). Any use of `unsafe-inline` or `unsafe-eval` MUST be documented with justification.
|
||||||
|
|
||||||
|
### FR-19 — Security alerting
|
||||||
|
Sentry alert rules MUST be configured for repeated authentication failures and authorization violations (D-30, SECURITY-14). The corresponding events MUST be emitted by the application with enough context to alert on, and MUST NOT contain passwords, tokens or PII.
|
||||||
|
|
||||||
|
### FR-20 — Database backup before production deploy
|
||||||
|
A database backup MUST be taken before every production deployment (D-26). If it cannot be automated from the runner, the deployment instructions MUST make it an explicit, verifiable manual step in the production procedure.
|
||||||
|
|
||||||
|
### FR-21 — Fix existing lint errors
|
||||||
|
The 5 errors and 1 warning currently reported by `pnpm run lint` MUST be fixed (D-11): `AddCmsInstanceDialog.tsx:55`, `InviteUserDialog.tsx:50` and `:54`, `SettingsPage.tsx:40`, `SetStatusDialog.tsx:32` and `:72`.
|
||||||
|
|
||||||
|
### FR-22 — Pin vulnerable packages
|
||||||
|
`Microsoft.OpenApi` (currently 2.0.0) and `System.Security.Cryptography.Xml` (currently 10.0.9) MUST be pinned to patched versions so the blocking vulnerability gate passes (D-12).
|
||||||
|
|
||||||
|
### FR-24 — Validate the token in the availability gate's admin bypass
|
||||||
|
*(Added 2026-07-27 at Application Design, Q12 = A — resolves OPEN-02.)*
|
||||||
|
|
||||||
|
`AvailabilityMiddleware.IsAdminBypass` currently calls `JwtSecurityTokenHandler.ReadJwtToken`, which parses a token **without validating its signature**. An unauthenticated caller can therefore forge an unsigned token carrying an `Owner` role claim and bypass the availability gate.
|
||||||
|
|
||||||
|
The bypass MUST instead rely on a properly authenticated principal — either by validating the token with the same parameters used by the JWT bearer scheme, or by evaluating `HttpContext.User` after authentication has run. Protected endpoints already authenticate correctly, so this is not a privilege escalation; the defect is that the gate itself is bypassable. *(SECURITY-08.)*
|
||||||
|
|
||||||
|
Existing behaviour that MUST be preserved: an Owner or Administrator with a **valid** token still passes the gate, so administrators can always reach the system to switch it back on.
|
||||||
|
|
||||||
|
### FR-23 — Deployment and rollback documentation
|
||||||
|
Operations artifacts MUST document: one-time host setup, required Gitea variables and secrets, the deployment procedure per environment, the rollback procedure, and how to switch production to FTPS when it moves to shared hosting (D-02).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Non-Functional Requirements
|
||||||
|
|
||||||
|
### NFR-01 — No server configuration required
|
||||||
|
Nothing in the deployment may depend on configuring nginx, IIS, or any server-side software beyond placing files and running the application. Anything normally handled by server configuration — security headers, routing, SPA fallbacks — must be handled inside the application. *(Core constraint.)*
|
||||||
|
|
||||||
|
### NFR-02 — Deployment safety
|
||||||
|
No deployment step may destroy data it does not own. Specifically: the customer's public website (FR-08), the database (FR-11, FR-20), and Data Protection keys (FR-12). This is the single highest-priority non-functional property of this feature.
|
||||||
|
|
||||||
|
### NFR-03 — Production cannot be deployed accidentally
|
||||||
|
Production deployment requires a deliberate, explicit action. No push, merge or ordinary workflow run may reach production (FR-04).
|
||||||
|
|
||||||
|
### NFR-04 — Minimal and predictable downtime
|
||||||
|
Deployment downtime is limited to the process restart in the atomic switch (FR-06). The public website remains reachable throughout the CMS deploy.
|
||||||
|
|
||||||
|
### NFR-05 — Reproducible builds
|
||||||
|
Tool versions and actions are pinned; `pnpm install --frozen-lockfile` is used. *(SECURITY-10; note the absence of `packages.lock.json` — see DEV-02.)*
|
||||||
|
|
||||||
|
### NFR-06 — Observability sufficient to trust a deployment
|
||||||
|
After a deploy it must be possible to determine, without host access, whether the application is alive (FR-10), whether it is throwing errors (FR-14, FR-15), and whether it is being used (FR-16). This matters more than usual here because `ModuleOrchestrator` logs rather than throws when a module fails to load, so a deployment can silently start with reduced capability.
|
||||||
|
|
||||||
|
### NFR-07 — Secrets never in source or logs
|
||||||
|
No credential, connection string or DSN in the repository or in workflow output. Credentials live in Gitea Actions secrets; runtime secrets live in host environment variables (D-16). *(SECURITY-12.)*
|
||||||
|
|
||||||
|
### NFR-08 — Separation of health from product state
|
||||||
|
Infrastructure health (`/health`) and CMS domain state (availability, capabilities) remain strictly separate concerns, in both implementation and documentation (FR-10).
|
||||||
|
|
||||||
|
### NFR-09 — Extensibility to a second hosting target
|
||||||
|
Adding FTPS for shared hosting must not require restructuring the workflow (FR-02, D-02).
|
||||||
|
|
||||||
|
### NFR-10 — Maintainability of the workflow
|
||||||
|
Environment-specific values are declared once and referenced, not duplicated across jobs — following the reference project's `env:` block plus `config` job pattern, which exists because the `env` context is unavailable in a reusable workflow's `with:` block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security Compliance (Security Baseline extension — enabled, blocking)
|
||||||
|
|
||||||
|
Assessed at Requirements Analysis. Rules are evaluated against what this feature's requirements commit to.
|
||||||
|
|
||||||
|
| Rule | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SECURITY-01 Encryption at rest/transit | **Addressed** | Connection strings must enforce TLS (`Encrypt=True`); HSTS via FR-18. Database-level encryption at rest depends on the host and is a documented host-setup step in FR-23. |
|
||||||
|
| SECURITY-02 Access logging on intermediaries | **N/A** | No load balancer, API gateway or CDN is part of this architecture — the application is the only network-facing component, covered by SECURITY-03. |
|
||||||
|
| SECURITY-03 Application-level logging | **Addressed** | FR-14, FR-20 (D-20): structured logging to Sentry with environment tagging, no secrets or PII. Correlation/request ID must be included — see OPEN-01. |
|
||||||
|
| SECURITY-04 HTTP security headers | **Addressed** | FR-18, with the path-scoped CSP of D-31. |
|
||||||
|
| SECURITY-05 Input validation | **Pre-existing, unchanged** | The API validates via model binding and typed requests, uses EF Core parameterised queries, and returns RFC 9457. This feature adds no new input surface except `/health`, which takes no input. |
|
||||||
|
| SECURITY-06 Least-privilege access policies | **Addressed** | Deploy credentials must be scoped to the deployment target only, and Gitea secrets scoped to this repository (FR-02, FR-23). |
|
||||||
|
| SECURITY-07 Restrictive network configuration | **Partially N/A** | No cloud networking to configure. What applies — not exposing the database publicly, and restricting SSH access — is a documented host-setup requirement in FR-23. |
|
||||||
|
| SECURITY-08 Application-level access control | **Improved** | Hierarchical policies, JWT validation, per-origin CORS are pre-existing. This feature adds `/health` as a deliberately anonymous endpoint exposing no data beyond liveness, and **fixes** the pre-existing unvalidated-JWT gap in `AvailabilityMiddleware.IsAdminBypass` (`code-quality-assessment.md` item 16) via FR-24. |
|
||||||
|
| SECURITY-09 Hardening and misconfiguration | **Addressed** | Scalar/OpenAPI already Development-only; production errors already go through `GlobalExceptionHandler` as `ProblemDetails`; no default credentials (D-16); static-file serving must not enable directory browsing. |
|
||||||
|
| SECURITY-10 Supply chain | **Addressed** | Blocking vulnerability gate (FR-01, FR-22), pinned tool versions and actions (FR-01), `--frozen-lockfile`. SBOM generation and the missing `packages.lock.json` — see DEV-02. |
|
||||||
|
| SECURITY-11 Secure design | **Addressed** | Rate limiting already exists on login/refresh; security-critical logic is already isolated in `Core/Identity`; the misuse case explicitly considered by this feature is a deployment destroying the customer's website (NFR-02) and a redeploy silently breaking master↔slave trust (FR-12). |
|
||||||
|
| SECURITY-12 Authentication and credentials | **Pre-existing, unchanged, plus FR-12** | Identity password policy meets the 8-character minimum with complexity; httpOnly/Secure/SameSite refresh cookie; rate-limited login. Breached-password-list checking and MFA are **not** implemented — see DEV-03. |
|
||||||
|
| SECURITY-13 Software and data integrity | **Addressed** | External scripts (Umami) must be loaded with SRI where the provider supports it, and are constrained by CSP (FR-18). Pipeline definitions are version-controlled and reviewable. Data-modification auditing beyond what exists — see DEV-04. |
|
||||||
|
| SECURITY-14 Alerting and monitoring | **Addressed with documented deviation** | Alerting via FR-19; monitoring via FR-17. Retention deviates — see DEV-01. |
|
||||||
|
| SECURITY-15 Exception handling and fail-safe defaults | **Pre-existing, unchanged** | `GlobalExceptionHandler` is registered globally; `UseExceptionHandler` is first in the pipeline; the availability gate fails closed while the master gate fails open **by deliberate design** (an unreachable Master must never permanently disable a customer site) — documented as an intentional, business-driven exception to "fail closed". |
|
||||||
|
|
||||||
|
### Documented Deviations
|
||||||
|
|
||||||
|
| ID | Deviation | Rationale | Decided |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DEV-01 | **Log retention below the 90-day SECURITY-14 minimum.** Sentry's free plan retains events for ~30 days. | Accepted knowingly; extending retention is a cost decision, not a technical one. Revisit if a compliance requirement appears. | CQ4 = A |
|
||||||
|
| DEV-02 | **No `packages.lock.json` for .NET projects, and no SBOM generation.** | The frontend is locked via `pnpm-lock.yaml`; the .NET side relies on the blocking vulnerability gate instead. Recorded as follow-up work rather than silently ignored. | Derived from SECURITY-10 assessment |
|
||||||
|
| DEV-03 | **No breached-password-list checking and no MFA.** | Pre-existing product scope, unrelated to deployment. Belongs to an Identity feature, not this one. | Derived from SECURITY-12 assessment |
|
||||||
|
| DEV-04 | **No before/after audit trail on critical data changes.** | Pre-existing; `CmsInstance` does track `LastStatusPushedAt`/`LastContactedAt`, but there is no general audit log. Out of scope for a deployment feature. | Derived from SECURITY-13 assessment |
|
||||||
|
| DEV-05 | **Data Protection keys are stored unencrypted at rest** in the database, whereas SECURITY-01 requires encryption at rest for persisted data. *(Added 2026-07-27 at U2 Functional Design.)* | DPAPI is unavailable on Linux, and X.509 certificate encryption relocates the loss problem to the certificate — reintroducing the very failure mode FR-12 exists to eliminate. Compensating controls per BR-U2-08: TLS enforced on the database connection, and the database not publicly reachable. Certificate-based encryption is recorded as a separate follow-up. | U2 FD Q2 = C |
|
||||||
|
|
||||||
|
DEV-01…04 are **pre-existing or cost-driven** and none is introduced by this feature. **DEV-05 is the one deviation this feature does introduce** — it is a consequence of moving the key ring into the database, which on balance removes a far larger risk (silent, permanent loss of Master↔slave trust on every redeploy) than it adds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Assumptions
|
||||||
|
|
||||||
|
| ID | Assumption | Why it matters | If wrong |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ASM-01 | With the atomic release switch (FR-06), `wwwroot/web/` must live **outside** the swapped release directory and be linked into it (e.g. a symlink to a persistent path on the host). | Otherwise switching releases silently discards the customer's website — precisely the failure mode D-06 was chosen to prevent. This follows necessarily from combining Q4 = C with CQ3 = C, so it is stated rather than asked. | Raise it and the deploy design changes materially; flag before Construction if this is not acceptable. |
|
||||||
|
| ASM-02 | **No `wwwroot` folder is needed for the API.** The API is not static content — its assemblies live in the application root and it serves `/api/v1` through routing. The option is kept open but nothing is built for it. | Avoids building an unused folder. | If something static under an API path is intended, say so and FR-07 gains a third mount. |
|
||||||
|
| ASM-03 | The Pi already runs, or can run, a .NET 10 runtime, and the app is managed by a process manager (systemd) that the deploy can restart over SSH. | The atomic switch requires restarting the process. | Restart mechanism changes; deploy step is rewritten. |
|
||||||
|
| ASM-04 | The Pi's SQL Server database is reachable from the application, and a backup can be taken before a production deploy. | FR-20 depends on it. | FR-20 becomes a documented manual precondition only. |
|
||||||
|
| ASM-05 | The existing Umami instance at `analytics.slpsoftware.nl` remains available and its script origin can be added to the CSP. | FR-16, FR-18. | Umami setup gains host work, as in the reference project. |
|
||||||
|
| ASM-06 | One Sentry project with environment tags is acceptable for both backend and frontend events of this CMS. | D-19. | Split into more projects; only configuration changes. |
|
||||||
|
| ASM-07 | Existing Gitea secrets for the Pi (`PI_MAIN_*` in the reference project) can be reused or replicated for this repository. | FR-02. | New secrets are created; documented in FR-23. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Items
|
||||||
|
|
||||||
|
| ID | Item | To be resolved |
|
||||||
|
|---|---|---|
|
||||||
|
| OPEN-01 | **Correlation/request ID in logs** is required by SECURITY-03 but does not exist today. Needs a decision on mechanism (ASP.NET Core `TraceIdentifier` versus `W3C traceparent`). | NFR Design / Construction |
|
||||||
|
| ~~OPEN-02~~ | ~~`AvailabilityMiddleware.IsAdminBypass` reads the JWT without validating its signature.~~ **RESOLVED 2026-07-27** at Application Design (Q12 = A): folded into this feature as **FR-24**, landing in the same unit as the `/health` bypass since both touch the same middleware. | Closed |
|
||||||
|
| OPEN-03 | **Exact patched versions** for `Microsoft.OpenApi` and `System.Security.Cryptography.Xml` (FR-22) must be resolved and verified against the advisories. | Construction |
|
||||||
|
| OPEN-04 | **Whether production stays on the Pi long enough** that FTPS is never built. D-02 requires only that the design allows it; the trigger for actually building it is a business decision. | Deferred by design |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Summary
|
||||||
|
|
||||||
|
This feature turns a manually deployed modular-monolith CMS into one with an automated, auditable pipeline, on hosting where nothing can be configured server-side.
|
||||||
|
|
||||||
|
**24 functional requirements, 10 non-functional requirements, 32 traced decisions, 7 assumptions, 3 remaining open items, 4 documented security deviations.**
|
||||||
|
|
||||||
|
*(FR-24 added and OPEN-02 closed at Application Design on 2026-07-27.)*
|
||||||
|
|
||||||
|
The three requirements that carry the most risk if implemented carelessly:
|
||||||
|
|
||||||
|
1. **FR-08 with ASM-01** — the customer's public website must survive every deploy. Combining the `wwwroot/web/` split with an atomic release switch protects it, but only if `web/` lives outside the swapped directory.
|
||||||
|
2. **FR-12** — a persistent Data Protection key ring, without which a redeploy silently breaks master↔slave trust in a way that looks like a network problem.
|
||||||
|
3. **FR-04** — production must be unreachable by accident.
|
||||||
|
|
||||||
|
Three findings from Reverse Engineering are resolved as a by-product rather than left as debt: the migration asymmetry (FR-11), the environment-coupled SPA bundle (FR-13), and the ephemeral key ring (FR-12).
|
||||||
Reference in New Issue
Block a user