From 8568ca43c6bf3f978854c0b3885712acde7a18eb Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Mon, 27 Jul 2026 23:59:17 +0200 Subject: [PATCH 01/35] 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 Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw --- .../reverse-engineering/api-documentation.md | 333 +++++++++++----- .../reverse-engineering/architecture.md | 285 ++++++++++---- .../reverse-engineering/business-overview.md | 128 +++--- .../code-quality-assessment.md | 125 ++++-- .../reverse-engineering/code-structure.md | 326 +++++++++++----- .../component-inventory.md | 58 ++- .../reverse-engineering/dependencies.md | 237 ++++++++---- .../reverse-engineering-timestamp.md | 21 +- .../reverse-engineering/technology-stack.md | 98 +++-- aidlc-docs/active-features.md | 1 + aidlc-docs/feature-selection.md | 74 +++- .../application-design/application-design.md | 185 +++++++++ .../component-dependency.md | 249 ++++++++++++ .../application-design/component-methods.md | 275 +++++++++++++ .../application-design/components.md | 159 ++++++++ .../inception/application-design/services.md | 170 ++++++++ .../unit-of-work-dependency.md | 137 +++++++ .../unit-of-work-story-map.md | 126 ++++++ .../application-design/unit-of-work.md | 209 ++++++++++ .../plans/application-design-plan.md | 289 ++++++++++++++ .../inception/plans/execution-plan.md | 284 ++++++++++++++ .../inception/plans/unit-of-work-plan.md | 170 ++++++++ .../requirement-clarification-questions.md | 103 +++++ .../requirement-verification-questions.md | 328 ++++++++++++++++ .../inception/requirements/requirements.md | 366 ++++++++++++++++++ 25 files changed, 4233 insertions(+), 503 deletions(-) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/application-design.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-dependency.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-methods.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/components.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/services.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-dependency.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-story-map.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/plans/application-design-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/plans/execution-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/plans/unit-of-work-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-clarification-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-verification-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirements.md diff --git a/aidlc-docs/_shared/reverse-engineering/api-documentation.md b/aidlc-docs/_shared/reverse-engineering/api-documentation.md index 2372aac..66cfc0c 100644 --- a/aidlc-docs/_shared/reverse-engineering/api-documentation.md +++ b/aidlc-docs/_shared/reverse-engineering/api-documentation.md @@ -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 `). Token validation uses `ClockSkew.Zero`, so expiry is exact. +- **Refresh token**: returned as an httpOnly cookie named `refreshToken`, `SameSite=Strict` (`None` in Development), scoped to path `/api/v1/auth`. It is deliberately `[JsonIgnore]`d out of the response body. +- **Authorization policies**: `OwnerOnly`, `AdminOnly`, `UserOnly` — hierarchical, so a higher role satisfies a lower requirement. +- **Errors**: RFC 9457 `ProblemDetails` via `GlobalExceptionHandler`. +- **Enums**: serialized as strings (`JsonStringEnumConverter`). +- **Rate limiting**: named limiters `login` (fixed window, default 5/60s) and `refresh` (sliding window, default 20/60s, 4 segments); rejections return `429`. +- **Availability gate**: unless a path is on the bypass list or the caller presents an Owner/Administrator token, requests are blocked with `503 ProblemDetails` when the master gate or the local status says unavailable. Bypass prefixes: `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus`. +- **API reference UI**: Scalar at `/scalar` and the OpenAPI document are mapped **in Development only**. + +## Non-API routes served by the same host + +| Path | Behaviour | +|---|---| +| `/` and `/{*path:nonfile}` | Public website from `wwwroot/`, with SPA fallback to `wwwroot/index.html`. Served by static-file middleware **before** the availability gate is installed. | +| `/admin` and `/admin/{*path:nonfile}` | Admin SPA from `wwwroot/admin/`, fallback to `wwwroot/admin/index.html`. | +| Any path with a file extension that does not exist | Returns `404` — the `nonfile` route constraint deliberately excludes it from the SPA fallbacks. | ## REST APIs -### Authentication +### Authentication — `Modules.Identity/AuthController` (`/api/v1/auth`) -#### POST /auth/login +#### Login - **Method**: POST -- **Path**: `/auth/login` -- **Purpose**: Authenticate a user and receive JWT tokens -- **Authorization**: Anonymous -- **Request**: `{ "email": string, "password": string }` -- **Response**: `{ "accessToken": string, "expiresAt": datetime, "user": { "id": guid, "email": string, "name": string, "role": string, "isActive": bool } }` -- **Cookie set**: `refreshToken` (httpOnly, Secure, SameSite=Strict, Path=/api/v1/auth) +- **Path**: `/api/v1/auth/login` +- **Purpose**: Authenticate a user and start a session. +- **Auth**: Anonymous. Rate limiter `login`. +- **Request**: `LoginRequest { email, password }` +- **Response**: `200` `TokenResponse { accessToken, expiresAt, user { id, email, name, role, isActive } }` plus a `refreshToken` cookie. -#### POST /auth/refresh +#### Refresh - **Method**: POST -- **Path**: `/auth/refresh` -- **Purpose**: Refresh an access token using the httpOnly refresh token cookie -- **Authorization**: Anonymous -- **Request**: (empty body — refresh token read from cookie) -- **Response**: Same as `/auth/login` (new access token + new cookie) +- **Path**: `/api/v1/auth/refresh` +- **Purpose**: Rotate the refresh token and issue a new access token (used for silent refresh on SPA startup). +- **Auth**: Anonymous — authority comes from the cookie. Rate limiter `refresh`. +- **Request**: No body; reads the `refreshToken` cookie. +- **Response**: `200` `TokenResponse` plus a replaced cookie; `401` when the cookie is missing or invalid. -#### POST /auth/revoke +#### Revoke - **Method**: POST -- **Path**: `/auth/revoke` -- **Purpose**: Revoke a refresh token (logout) -- **Authorization**: Bearer JWT required -- **Request**: `""` (string body) -- **Response**: 204 No Content +- **Path**: `/api/v1/auth/revoke` +- **Purpose**: Log out — revoke the refresh token and clear the cookie. +- **Auth**: Anonymous (the cookie carries the authority). +- **Response**: `200`. ---- +#### Change password +- **Method**: POST +- **Path**: `/api/v1/auth/change-password` +- **Purpose**: Replace the caller's own password. +- **Auth**: Any authenticated user. +- **Request**: `ChangePasswordRequest { currentPassword, newPassword }` +- **Response**: `200`, or `ProblemDetails` on validation failure. -### Setup +### Setup — `Modules.Identity/SetupController` (`/api/v1/Setup`) -#### GET /setup/status +#### Get setup status - **Method**: GET -- **Path**: `/setup/status` -- **Purpose**: Check if the system has been initialized (first owner created) -- **Authorization**: Anonymous -- **Response**: `{ "initialized": boolean }` +- **Path**: `/api/v1/Setup/status` +- **Purpose**: Tell a client whether the system still needs bootstrapping. On the availability bypass list. +- **Auth**: Anonymous. +- **Response**: `200` `{ initialized: bool }`. -#### POST /setup/owner +#### Create initial owner - **Method**: POST -- **Path**: `/setup/owner` -- **Purpose**: Create the initial Owner account (only usable when system is not yet initialized) -- **Authorization**: Anonymous -- **Request**: `{ "email": string, "password": string }` -- **Response**: `{ "message": string }` +- **Path**: `/api/v1/Setup/owner` +- **Purpose**: One-time creation of the first Owner account. +- **Auth**: Anonymous (only meaningful while uninitialized). +- **Request**: `CreateOwnerRequest { name, email, password }` +- **Response**: `200` `{ message }`. ---- +### Invitations — `Modules.Identity/InvitationController` (`/api/v1/Invitation`) -### Users - -#### POST /users/invite -- **Method**: POST -- **Path**: `/users/invite` -- **Purpose**: Invite a new user by email with a specified role -- **Authorization**: Bearer JWT, Policy: AdminOnly -- **Request**: `{ "email": string, "role": string }` -- **Response**: `{ "inviteLink": string }` - -#### POST /users/complete-setup -- **Method**: POST -- **Path**: `/users/complete-setup` -- **Purpose**: Complete account setup using an invitation token -- **Authorization**: Anonymous -- **Request**: `{ "token": string, "password": string }` -- **Response**: `{ "message": string }` - -#### GET /users/validate-invitation +#### Validate invitation - **Method**: GET -- **Path**: `/users/validate-invitation?token={token}` -- **Purpose**: Validate an invitation token before showing the setup form -- **Authorization**: Anonymous -- **Response**: `{ "valid": boolean, "email": string, "role": string }` or error +- **Path**: `/api/v1/Invitation/validate?token={token}` +- **Purpose**: Check an invitation token before showing the registration form. +- **Auth**: Anonymous. +- **Response**: `200` `{ isValid, email, name, errorCode }` — an invalid token is reported in the body (e.g. `errorCode: "NOT_FOUND"`), not as an error status. ---- - -### Availability - -#### GET /availability/status -- **Method**: GET -- **Path**: `/availability/status` -- **Purpose**: Get current system availability status -- **Authorization**: Anonymous -- **Response**: `{ "status": "Available|Maintenance|Unavailable", "checkedAt": datetime, "message": string }` - -#### POST /availability/admin/status +#### Complete invitation - **Method**: POST -- **Path**: `/availability/admin/status` -- **Purpose**: Update the system availability status -- **Authorization**: Bearer JWT, Policy: OwnerOnly -- **Request**: `{ "newStatus": "Available|Maintenance|Unavailable", "reason": string }` -- **Response**: 200 OK or 400 Bad Request +- **Path**: `/api/v1/Invitation/complete` +- **Purpose**: Set a password and activate the invited account. +- **Auth**: Anonymous. +- **Request**: `CompleteSetupRequest { token, password }` +- **Response**: `200` `{ message }`. ---- +### Users — `Modules.Identity/UsersController` (`/api/v1/Users`) -## Authorization Policies +Controller default policy: `AdminOnly`. -| Policy | Required Role | Description | -|--------|--------------|-------------| -| `OwnerOnly` | Owner | Full system access including availability management | -| `AdminOnly` | Owner or Admin | User management access | +| Method | Path | Purpose | Auth | Request | Response | +|---|---|---|---|---|---| +| GET | `/api/v1/Users` | List users, including pending invitations | AdminOnly | — | `200` `UserDto[]` | +| PUT | `/api/v1/Users/me` | Update the caller's own profile | Any authenticated | `UpdateProfileRequest { name, email }` | `200` | +| POST | `/api/v1/Users/invite` | Invite a user and get an invite link | AdminOnly | `InviteUserRequest { email, role }` | `200` `{ token, inviteLink }`, link shaped `/invite/complete?token=…` | +| PUT | `/api/v1/Users/{userId:guid}/role` | Change a user's role | AdminOnly, hierarchy enforced | `ChangeRoleRequest { newRole }` | `200`, `404` if unknown | +| PUT | `/api/v1/Users/{userId:guid}/active` | Activate or deactivate a user | AdminOnly | `SetUserActiveRequest { isActive }` | `200`, `404` if unknown | +| DELETE | `/api/v1/Users/{userId:guid}` | Delete a user | AdminOnly | — | `200`, `404` if unknown | + +`UserDto { id, email, name, role, isActive, createdAt, invitationPending, inviteLink? }` + +### Availability — `Modules.Availability/AvailabilityController` (`/api/v1/Availability`) + +#### Get status +- **Method**: GET +- **Path**: `/api/v1/Availability/status` +- **Purpose**: Report this instance's availability. On the bypass list, so it answers even while the instance is gated off — the most useful existing endpoint for external monitoring. +- **Auth**: Anonymous. +- **Response**: `200` `{ status: "Available" | "NotAvailable" | "Maintenance" | "Degraded" | "Unknown", checkedAt, message, isMasterControlled }`. + +#### Update status +- **Method**: POST +- **Path**: `/api/v1/Availability/admin/status` +- **Purpose**: Owner switches the local availability status. +- **Auth**: `OwnerOnly`. +- **Request**: `UpdateStatusRequest { newStatus, reason }` +- **Response**: `200`; `409 ProblemDetails` when the Master controls this instance's status (`MasterControlledAvailabilityException`); `400` if the active availability service does not support updates. + +### Master-side inbound endpoints on a slave — `Modules.Availability/MasterController` (`/api/v1/master`) + +All three authenticate with the `X-Master-Api-Key` header rather than JWT, and are on the availability bypass list so a Master can always reach a gated-off slave. + +| Method | Path | Purpose | Request | Response | +|---|---|---|---|---| +| POST | `/api/v1/master/register` | Master registers itself with this instance | `RegisterMasterRequest { masterUrl }` + `X-Master-Api-Key` | `200`, `401` without the key | +| POST | `/api/v1/master/status` | Master pushes this instance's status | `PushStatusRequest { isAvailable, disableMessage? }` + `X-Master-Api-Key` | `200`, `401` without the key | +| GET | `/api/v1/master/registered-url` | Report which Master this instance is bound to | `X-Master-Api-Key` | `200`, `401` without the key | + +### CMS instance management on the Master — `Modules.Master/CmsInstanceController` (`/api/v1/CmsInstances`) + +Controller policy: `OwnerOnly`. Present only on instances that ship the Master module. + +| Method | Path | Purpose | Request | Response | +|---|---|---|---|---| +| GET | `/api/v1/CmsInstances` | List registered instances | — | `200` `CmsInstanceDto[]` | +| POST | `/api/v1/CmsInstances` | Register an instance and push the registration to it | `CreateCmsInstanceRequest { name, url, apiKey }` | `200`, error `ProblemDetails` on failure | +| PUT | `/api/v1/CmsInstances/{id:guid}/status` | Set an instance's status and push it | `UpdateStatusRequest { status, disableMessage? }` | `200` `UpdateStatusResult { success, slaveContactSuccess }` | + +`CmsInstanceDto { id, name, url, status, disableMessage?, lastContactedAt?, lastStatusPushedAt?, lastIntegrityCheckFailedAt? }` +`CmsInstanceStatus`: `Available` (0), `NotAvailable` (1), `Inactive` (2). + +`UpdateStatusResult` deliberately separates "the Master recorded it" from "the slave acknowledged it" — a status change can succeed locally while the push fails, which the periodic integrity check later repairs. + +### Slave status poll on the Master — `Modules.Master/SlaveStatusController` (`/api/v1/SlaveStatus`) + +- **Method**: GET +- **Path**: `/api/v1/SlaveStatus` +- **Purpose**: Lets a slave pull its own authoritative status from the Master. This is the guard against local tampering and missed pushes. +- **Auth**: `[AllowAnonymous]` at the JWT level; authenticated by `X-Master-Api-Key`. On the availability bypass list. +- **Response**: `200` with the caller's status, `401` without the key. + +### System — `Core/Hosting/SystemController` (`/api/v1/System`) + +#### Get capabilities +- **Method**: GET +- **Path**: `/api/v1/System/capabilities` +- **Purpose**: Report which optional modules are loaded, so a client can hide features this deployment does not have rather than interpreting a 404. +- **Auth**: Anonymous. **Not** on the availability bypass list, so it returns `503` while the instance is gated off. +- **Response**: `200` `{ modules: string[] }` — e.g. `["Identity","Availability","Master"]` on a Master, `["Identity","Availability"]` on a slave. + +## Observability endpoints + +**None exist.** There is no `MapHealthChecks`, no `/health`, `/healthz` and no readiness or liveness endpoint anywhere in the solution. A dedicated health-check endpoint therefore has to be built before external uptime monitoring can be wired up meaningfully. + +**`/api/v1/Availability/status` and `/api/v1/System/capabilities` are not health checks** and must not be repurposed as such. Both are CMS domain functionality: + +- **Availability** is the product's own on/off state — the local maintenance switch plus the master gate. It answers the business question "should this site currently serve visitors?", which is deliberately independent of whether the application is healthy. A perfectly healthy instance reports `NotAvailable` when an Owner or its Master has switched it off, and a sick instance can still report `Available`. +- **Capabilities** reports which modules are loaded, so a client can hide features this deployment does not have. It says nothing about whether those modules are functioning. + +Both also serve the master↔slave protocol rather than operations. Conflating either with health monitoring would produce alerts on intentional business state and silence on genuine outages. + +A health check is a separate concern. It needs its own endpoint, deliberately outside `/api/v1` domain routing and outside the availability gate, reporting on infrastructure liveness (process up, database reachable, migrations applied) rather than on product state. + +**In scope for the `gitea-deployment-workflow` feature** (decided 2026-07-27), because ASP.NET Core provides this out of the box: + +- `builder.Services.AddHealthChecks()` and `app.MapHealthChecks("/health")` need **no package at all** — both live in the shared framework. Default output is plain text `Healthy` with `200` or `Unhealthy` with `503`, which is exactly what an HTTP-probe monitor consumes. +- Adding a database probe costs one package, `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` **10.0.9** (in line with the rest of the 10.0.x dependencies), and one call: `.AddDbContextCheck()`. 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 ModuleNames { get; }`, `DiscoverModules()`, `RegisterModuleServices(IServiceCollection)`, `UseModules(IApplicationBuilder)` +- **Behaviour**: `DiscoverModules` globs `SlpModularCms.Modules.*.dll` in `AppDomain.CurrentDomain.BaseDirectory` and instantiates every concrete `IModule`. Load and instantiation failures are logged, not rethrown — a broken module degrades capability silently rather than failing startup. + +### `IAvailabilityService` (`Core/Availability`) +- **Methods**: `IsAvailableAsync()`, `GetStatusDetailsAsync()`, and on `PersistentAvailabilityService` also `UpdateStatusAsync(status, reason, updatedBy)` +- **Returns**: `AvailabilityStatus` / `AvailabilityStatusDetails { Status, Message?, IsMasterControlled }` +- **Validation**: throws `MasterControlledAvailabilityException` when a local update is attempted while master-controlled. + +### `IMasterAvailabilityService` (`Modules.Availability/Services`) +- **Methods**: `GetMasterStatus()` → `MasterGateStatus { IsAvailable, DisableMessage? }`, plus registration and push handling. +- **Purpose**: Supplies the master gate its verdict, including the fail-open decision when the Master has been unreachable beyond `MasterPolling:FailOpenAfterMinutes`. + +### `ISlaveApiClient` (`Modules.Master/Services`) +- **Purpose**: Outbound HTTP to slaves (register, push status). Wrapped in a `slave-resilience` pipeline: 2 retries, exponential backoff with jitter, timeout from `MasterModule:HttpTimeoutSeconds`. + +### `IApiKeyProtector` / `IMasterApiKeyProtector` +- **Purpose**: Encrypt and decrypt slave API keys using ASP.NET Core Data Protection. Both rely on the default file-system key ring; no persistent store is configured. + +### `IAuthService`, `IInvitationService`, `ISetupService` (`Core/Identity/Services`) +- **Purpose**: Authentication with refresh-token rotation, invitation lifecycle, and first-Owner bootstrap respectively. + +### Frontend `ApiClient` (`frontend/src/lib/api-client.ts`) +- **Purpose**: Single `fetch` wrapper for the SPA. Always sends credentials so the refresh cookie travels; holds the access token in memory only; on `401` runs a refresh-and-retry once; throws `ProblemDetailsError` for non-2xx and `NetworkError` for transport failures. +- **Base URL**: `VITE_API_BASE_URL`, validated as an absolute URL by `frontend/src/lib/config.ts`. ## Data Models -### AuthResponse -- `accessToken` — short-lived JWT (e.g. 15 min) -- `refreshToken` — long-lived opaque token -- `expiresAt` — access token expiry datetime -- `user` — authenticated user info +### `ApplicationUser` (extends `IdentityUser`) +- **Fields**: identity fields plus `Name`, `IsActive`, `CreatedAt`. +- **Relationships**: roles via Identity; `RefreshToken`s; `Invitation`s. -### Password Validation Rules (enforced by backend) -Configured in `ServiceCollectionExtensions.cs` via ASP.NET Core Identity `PasswordOptions`: -- `RequiredLength = 8` — minimum 8 characters -- `RequireUppercase = true` — at least 1 uppercase letter -- `RequireLowercase = true` — at least 1 lowercase letter -- `RequireDigit = true` — at least 1 digit -- `RequireNonAlphanumeric = true` — at least 1 non-alphanumeric character (e.g. `!@#$%^&*`) +### `ApplicationRole` (extends `IdentityRole`) +- **Fields**: identity role fields. Roles in use: `Owner`, `Administrator`, `User`. -### ApplicationUser (returned in auth responses) -- `id` — Guid -- `email` — string -- `name` — string (display name) -- `role` — string (Owner / Admin / User) -- `isActive` — boolean +### `RefreshToken` +- **Fields**: token value, expiry, revocation state, owning user. +- **Validation**: rotated on every refresh; the previous token is revoked. -### Invitation -- `token` — string (URL-safe token) -- `email` — string -- `role` — string -- `expiryDate` — datetime -- `isUsed` — boolean +### `Invitation` +- **Fields**: token, target email, role, expiry, used flag. +- **Validation**: single-use and time-limited; `InvitationOrUserAlreadyExistsException` guards duplicates. + +### `ModulePermission` +- **Fields**: links a role or user to a module's permission. Stored in `ApplicationDbContext`. + +### `GlobalAvailabilityState` +- **Fields**: current `AvailabilityStatus`, optional message, last-updated metadata. +- **Notes**: single-row state read by `PersistentAvailabilityService` behind a short cache and circuit breaker (`Availability:StatusCacheSeconds`, `Availability:CircuitBreakerSeconds`). + +### `MasterRegistration` (`AvailabilityDbContext`) +- **Fields**: master URL, encrypted master API key, last-known pushed status and message, `LastPolledAt`. +- **Notes**: its absence makes the master gate inert — the reason `MasterPolling` settings have no effect on an unregistered instance. + +### `CmsInstance` (`MasterDbContext`) +- **Fields**: `Id`, `Name`, `Url`, `Status` (`CmsInstanceStatus`), `DisableMessage?`, encrypted API key, `LastContactedAt?`, `LastStatusPushedAt?`, `LastIntegrityCheckFailedAt?`. +- **Notes**: the API key is stored Data 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 }` diff --git a/aidlc-docs/_shared/reverse-engineering/architecture.md b/aidlc-docs/_shared/reverse-engineering/architecture.md index 0557557..7ec4b91 100644 --- a/aidlc-docs/_shared/reverse-engineering/architecture.md +++ b/aidlc-docs/_shared/reverse-engineering/architecture.md @@ -1,120 +1,235 @@ -# System Architecture +# System Architecture ## System Overview -SlpModularCms is a modular, ASP.NET Core-based CMS platform. The backend is structured as a monolith-with-modules: a single API host (`SlpModularCms.Api`) that dynamically loads feature modules at startup. Each module is self-contained and registers its own services and HTTP middleware. Persistence is handled via Entity Framework Core with SQL Server. Authentication uses JWT Bearer tokens with refresh token rotation. +SlpModularCms is a **modular monolith** on .NET 10. A single ASP.NET Core host process discovers feature modules from disk at startup (`ModuleOrchestrator`), lets each register its own services and middleware, and exposes every controller under one `/api/v1` prefix via a global MVC convention. -The frontend is a React SPA (to be built) that communicates with the API via REST/JSON. The example app (from ZIP) provides the design foundation: Vite + React Router v7 + shadcn/ui + Tailwind CSS v4 with primary color `#ac0000`. +The defining architectural decision for deployment is **single-host serving** (commit `3885703`): because typical shared hosting allows only one site/application pool and no server configuration, the API process itself also serves the two frontends from `wwwroot`: + +| Path | Content | Origin | +|---|---|---| +| `/` | The customer's public website | Built and deployed **separately** — not part of this repository; lands in `wwwroot/` | +| `/admin` | The CMS admin SPA | Built from `frontend/` with Vite `base: '/admin/'`, copied to `wwwroot/admin/` by an MSBuild target on `dotnet publish` | +| `/api/v1/...` | The REST API | This solution | + +Both frontends get their own SPA fallback so client-side routes resolve, while genuinely missing assets still return 404. + +Persistence is EF Core on SQL Server. Three `DbContext` types share **one** connection string: `ApplicationDbContext` (Core/Identity), `AvailabilityDbContext` and `MasterDbContext`. The two module contexts migrate themselves at startup; the Core context does not and must be migrated explicitly. + +Authentication is JWT bearer with an httpOnly, rotating refresh-token cookie. Authorization is hierarchical (Owner > Administrator > User). Errors follow RFC 9457 `ProblemDetails`. ## Architecture Diagram ```mermaid graph TD - subgraph ClientLayer["Client Layer"] - Frontend["React SPA\nVite + TanStack Router + shadcn/ui\nTailwind CSS v4 #ac0000"] + visitor["Public visitor"] + adminuser["Admin user (browser)"] + + subgraph host["SlpModularCms.Api — single host process"] + static["Static files + SPA fallbacks
wwwroot/ and wwwroot/admin/"] + pipeline["Middleware pipeline
exception handler, rate limiter,
HTTPS redirect, CORS, availability gate, auth"] + orchestrator["ModuleOrchestrator
assembly discovery"] + core["SlpModularCms.Core
identity, authz, module contract,
routing convention, error handling"] + modidentity["Modules.Identity
auth, setup, invitations, users"] + modavail["Modules.Availability
availability gate, master registration"] + modmaster["Modules.Master
instance registry, status push"] end - subgraph ApiLayer["API Layer"] - Api["SlpModularCms.Api\nASP.NET Core\nJWT Bearer, CORS, Swagger"] - Identity["Identity Module\nAuthController\nSetupController\nUsersController"] - Avail["Availability Module\nAvailabilityController\nPersistentService + CircuitBreaker"] - end + db[("SQL Server
ApplicationDbContext
AvailabilityDbContext
MasterDbContext")] + slaveinst["Slave CMS instances
(separate deployments)"] - subgraph CoreLayer["Core Layer"] - Core["SlpModularCms.Core\nApplicationDbContext\nDomain Entities\nIdentity Services\nIModule interface"] - end + visitor --> static + adminuser --> static + adminuser --> pipeline + pipeline --> orchestrator + orchestrator --> modidentity + orchestrator --> modavail + orchestrator --> modmaster + modidentity --> core + modavail --> core + modmaster --> core + core --> db + modavail --> db + modmaster --> db + modmaster -->|"HTTP push: register + status"| slaveinst + slaveinst -->|"HTTP pull: own status"| modmaster - subgraph DataLayer["Data Layer"] - DB[("SQL Server\nIdentity tables\nRefreshTokens\nInvitations\nGlobalAvailabilityState")] - end - - Frontend -->|HTTP REST / JSON| Api - Api --> Identity - Api --> Avail - Identity --> Core - Avail --> Core - Core --> DB - - style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff - style Api fill:#4CAF50,stroke:#2E7D32,color:#fff - style Identity fill:#4CAF50,stroke:#2E7D32,color:#fff - style Avail fill:#4CAF50,stroke:#2E7D32,color:#fff - style Core fill:#FFC107,stroke:#F57F17,color:#000 - style DB fill:#FF5722,stroke:#BF360C,color:#fff + classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef surface fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + classDef external fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; + class visitor,adminuser actor; + class static,pipeline,orchestrator surface; + class core corelayer; + class modidentity,modavail,modmaster module; + class db store; + class slaveinst external; ``` +Text alternative: One host process serves static frontends and an API; a module orchestrator loads the Identity, Availability and Master modules, which all build on Core and share one SQL Server database, while the Master module exchanges registration and status with separately deployed slave instances. + ## Component Descriptions ### SlpModularCms.Api -- **Purpose**: Web API host and application entry point -- **Responsibilities**: Bootstrap, module loading, middleware pipeline, CORS, Swagger -- **Dependencies**: SlpModularCms.Core, SlpModularCms.Modules.Identity, SlpModularCms.Modules.Availability -- **Type**: Application +- **Purpose**: Deployable host — the single site that serves everything. +- **Responsibilities**: Configuration composition (including optional `appsettings.local.json`); module discovery and activation; middleware pipeline; static files and SPA fallbacks; `/api/v1` prefix convention; enum-as-string JSON; publish-time admin SPA build. +- **Dependencies**: Core, Modules.Identity, Modules.Availability, Modules.Master. +- **Type**: Application (Client / deployable). + +### SlpModularCms.Api.Slave +- **Purpose**: Local second instance without the Master module, for exercising master↔slave behaviour. +- **Responsibilities**: Same host duties, minus central management. Uses its own database. +- **Dependencies**: Core, Modules.Identity, Modules.Availability. +- **Type**: Application (Client / deployable). No test project by design. ### SlpModularCms.Core -- **Purpose**: Shared domain layer -- **Responsibilities**: Domain entities, EF Core DbContext, authentication services, module interface -- **Dependencies**: EF Core, ASP.NET Identity, SQL Server provider -- **Type**: Shared Library +- **Purpose**: Shared foundation. +- **Responsibilities**: `ApplicationDbContext` and Identity entities; `AuthService`, `InvitationService`, `SetupService`; `HierarchicalRoleHandler` and the Owner/Admin/User policies; `IModule` + `ModuleOrchestrator`; `ApiPrefixConvention`; `GlobalExceptionHandler` and typed exceptions; `IAvailabilityService` contract; `SystemController` capability endpoint. +- **Dependencies**: EF Core + SQL Server provider, ASP.NET Core Identity, JwtBearer, Asp.Versioning, OpenAPI. References the ASP.NET Core shared framework. +- **Type**: Shared library. ### SlpModularCms.Modules.Identity -- **Purpose**: Identity and user management module -- **Responsibilities**: HTTP endpoints for auth, setup, and user invitation flows -- **Dependencies**: SlpModularCms.Core -- **Type**: Application Module +- **Purpose**: HTTP surface for accounts and access. +- **Responsibilities**: `AuthController`, `SetupController`, `InvitationController`, `UsersController`. Holds no persistence of its own. +- **Dependencies**: Core. +- **Type**: Application module. ### SlpModularCms.Modules.Availability -- **Purpose**: System availability tracking module -- **Responsibilities**: Exposes system status, allows owners to update it, caches with circuit breaker -- **Dependencies**: SlpModularCms.Core -- **Type**: Application Module +- **Purpose**: Decides whether this instance serves requests. +- **Responsibilities**: `AvailabilityMiddleware` (dual gate: master gate then local status, with bypass prefixes and admin-token bypass); `PersistentAvailabilityService`; `AvailabilityDbContext` holding `MasterRegistration`; `MasterController` for inbound master calls; `MasterStatusPollingBackgroundService` (pull + fail-open); Data Protection–encrypted master API key. +- **Dependencies**: Core. +- **Type**: Application module. Self-migrates at startup. -### SlpModularCms.Frontend (To Be Built) -- **Purpose**: Admin SPA for CMS management -- **Responsibilities**: Login, dashboard, user management, CMS content management, availability status display -- **Dependencies**: SlpModularCms.Api (REST) -- **Type**: Frontend Application +### SlpModularCms.Modules.Master +- **Purpose**: Central control point over other instances. +- **Responsibilities**: `MasterDbContext` with `CmsInstance`; `CmsInstanceController` (Owner-only); `SlaveStatusController` (anonymous, API-key authenticated pull endpoint); `SlaveApiClient` with retry + timeout resilience; `ApiKeyProtector` (Data Protection); `IntegrityCheckBackgroundService` for periodic reconciliation. +- **Dependencies**: Core, `Microsoft.Extensions.Http.Resilience`. +- **Type**: Application module. Self-migrates at startup. + +### frontend (admin SPA) +- **Purpose**: Admin UI, served at `/admin` in production. +- **Responsibilities**: Auth with in-memory access token and silent refresh; pages for dashboard, users, invitations, profile, settings, CMS instances; capability and role guards; i18n (NL/EN); MSW-mocked tests. +- **Dependencies**: The API at `VITE_API_BASE_URL`. +- **Type**: Frontend application. Built into the API's `wwwroot/admin` on publish. ## Data Flow +### Login and silent refresh + ```mermaid sequenceDiagram - participant Browser - participant AuthController - participant AuthService - participant DB - - Note over Browser,DB: Login Flow - Browser->>AuthController: POST /auth/login - AuthController->>AuthService: AuthenticateAsync() - AuthService->>DB: Validate credentials - DB-->>AuthService: User found - AuthService-->>AuthController: access + refresh tokens - AuthController-->>Browser: 200 OK with tokens - - Note over Browser,DB: Invite Flow - Browser->>AuthController: POST /users/invite - AuthController->>AuthService: CreateInvitationAsync() - AuthService->>DB: Store Invitation entity - DB-->>AuthService: Stored - AuthService-->>AuthController: invite token - AuthController-->>Browser: 200 OK with invite link - - Note over Browser,DB: New User Setup - Browser->>AuthController: POST /users/complete-setup - AuthController->>AuthService: CompleteInvitationAsync() - AuthService->>DB: Set password, activate account - DB-->>AuthService: Updated - AuthService-->>AuthController: success - AuthController-->>Browser: 200 OK + box rgba(246,224,94,0.4) Client + participant B as Browser (admin SPA) + end + box rgba(99,179,237,0.4) Host + participant A as AuthController + participant S as AuthService + end + box rgba(214,188,250,0.4) Data + participant D as SQL Server + end + B->>A: POST /api/v1/auth/login + A->>S: authenticate credentials + S->>D: verify user and persist refresh token + D-->>S: ok + S-->>A: access token plus refresh token + A-->>B: 200 with access token, refresh cookie set + B->>A: POST /api/v1/auth/refresh on startup + A->>S: rotate refresh token + S->>D: revoke old and store new + D-->>S: ok + A-->>B: 200 with new access token and cookie ``` +Text alternative: The SPA logs in, the host verifies credentials and stores a refresh token, returning an access token plus an httpOnly cookie; on startup the SPA silently refreshes, rotating the stored token. + +### Master registers a slave and pushes status + +```mermaid +sequenceDiagram + box rgba(246,224,94,0.4) Operator + participant O as Owner + end + box rgba(99,179,237,0.4) Master instance + participant M as CmsInstanceController + participant K as ApiKeyProtector + participant C as SlaveApiClient + end + box rgba(154,230,180,0.4) Slave instance + participant SL as MasterController + end + O->>M: POST /api/v1/CmsInstances with slave URL + M->>K: generate and encrypt API key + K-->>M: protected key stored + M->>C: push registration + C->>SL: POST /api/v1/master/register with X-Master-Api-Key + SL-->>C: 200 registered + O->>M: PUT /api/v1/CmsInstances/{id}/status + M->>C: push new status + C->>SL: POST /api/v1/master/status + SL-->>C: 200 applied +``` + +Text alternative: The Owner adds a slave by URL; the Master generates and encrypts an API key, pushes the registration to the slave, and later pushes each status change synchronously. + +### Availability gate evaluation + +```mermaid +graph TD + req["Incoming request"] + bypass{"Bypass prefix?
Availability/status, Auth/,
Setup/status, master/, SlaveStatus"} + adminbp{"Owner or Administrator
bearer token?"} + mgate{"Master gate
available?"} + local{"Local status
Available?"} + pass["Continue pipeline"] + block["503 ProblemDetails"] + + req --> bypass + bypass -->|yes| pass + bypass -->|no| adminbp + adminbp -->|yes| pass + adminbp -->|no| mgate + mgate -->|no| block + mgate -->|yes| local + local -->|yes| pass + local -->|no| block + + classDef start fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; + class req start; + class bypass,adminbp,mgate,local decision; + class pass good; + class block bad; +``` + +Text alternative: Requests to bypass prefixes or carrying an Owner/Administrator token always pass; otherwise the master gate is checked first and then the local availability status, and failing either returns a 503 ProblemDetails. + ## Integration Points -- **External APIs**: None currently -- **Databases**: SQL Server (via EF Core) -- **Third-party Services**: None currently + +- **External APIs**: None inbound from third parties. Outbound: the Master module calls each registered slave's `/api/v1/master/*` endpoints; each slave calls its Master's `/api/v1/SlaveStatus`. Both are instances of this same product. +- **Databases**: One SQL Server database per instance, shared by three `DbContext` types via `ConnectionStrings:DefaultConnection`. +- **Third-party Services**: **None wired up.** There is currently no Sentry, Umami, structured-logging sink, or uptime/health endpoint anywhere in the codebase — logging is the default ASP.NET Core console provider only. ## Infrastructure Components -- **Deployment Model**: Single API process + React SPA (separate deploy or static files) -- **Authentication**: JWT Bearer tokens (HS256 or RS256 based on JwtSettings config) -- **Database Migrations**: EF Core Code-First migrations in SlpModularCms.Core/Migrations/ + +- **Deployment Model**: One published .NET application per instance, containing the API, the admin SPA under `wwwroot/admin/`, and the customer's public website under `wwwroot/`. Designed explicitly for shared hosting where **no server configuration is possible** — hence no reverse-proxy, nginx or container assumptions in the code. There are no CDK, Terraform, CloudFormation or Docker artifacts in the repository, and **no CI/CD pipeline exists yet** (no `.gitea/` directory). +- **Configuration**: Three-file appsettings pattern (`appsettings.json` baseline with placeholder values, `appsettings.Development.json`, gitignored `appsettings.local.json`). Production secrets are expected as environment variables using the `Section__Key` convention: `ConnectionStrings__DefaultConnection`, `JwtSettings__Secret`, `JwtSettings__Issuer`, `JwtSettings__Audience`, `MasterModule__MasterUrl`. +- **Networking**: `AllowedHosts` is `*`; CORS origins come from `Cors:AllowedOrigins` (empty in the production baseline — acceptable once the admin SPA is same-origin under `/admin`). `UseHttpsRedirection()` runs early in the pipeline and **no forwarded-headers middleware is configured**, which matters when the app sits behind a hosting provider's TLS-terminating proxy. +- **Database migrations**: `AvailabilityDbContext` and `MasterDbContext` call `Database.Migrate()` in their module's `UseModule`. `ApplicationDbContext` (Core/Identity) is **never** migrated automatically and requires an explicit `dotnet ef database update` or a generated SQL script per environment. +- **Key management**: Both `ApiKeyProtector` (Master) and `MasterApiKeyProtector` (Availability) use `services.AddDataProtection()` with the default file-system key ring. No persistent key store is configured, so a redeploy or recycle that discards the key folder makes stored slave API keys unreadable. + +## Deployment-Relevant Observations + +These are current facts about the code, recorded because they shape any deployment/CI design: + +1. **No CI/CD exists yet** — this repository has no `.gitea/workflows/`. +2. **`dotnet publish` on `SlpModularCms.Api` requires Node and pnpm** on the build machine: the `BuildAndCopyAdminFrontend` target runs `pnpm install --frozen-lockfile` and `pnpm build` before publish. +3. **The admin SPA needs an absolute API base URL at build time.** `frontend/src/lib/config.ts` reads `VITE_API_BASE_URL` and validates it as a URL, so the bundle is environment-specific — a test build and a production build cannot be the same artifact unless this is changed to a same-origin/relative default. +4. **No health endpoint exists** — there is no `MapHealthChecks`, `/health` or readiness/liveness route anywhere. One has to be added before uptime monitoring can be wired up. Note that `Availability` and `System/capabilities` are **CMS domain functionality, not health checks**: availability is the product's own on/off state (local switch plus master gate) and capabilities reports which modules are loaded — both also serve the master↔slave protocol. Neither reflects application health, so neither may be repurposed for monitoring; a healthy instance can report `NotAvailable` by design, and a sick one can report `Available`. +5. **The public website at `/` is not behind the availability gate.** Static files are served before `orchestrator.UseModules(app)` installs `AvailabilityMiddleware`, so an existing `wwwroot/index.html` short-circuits the pipeline. Turning an instance "off" therefore blocks the API and admin SPA routes but still serves the public site's static files — relevant both to what "disabled" means commercially and to what an uptime check actually proves. +6. **`ModuleOrchestrator` discovers modules by globbing `SlpModularCms.Modules.*.dll` in the app base directory.** Which modules an instance has is therefore a property of what is deployed, not of configuration — a deployment pipeline can shape capability by which DLLs it ships. +7. **Data Protection has no persistent key ring**, so redeploys risk invalidating stored slave API keys (already flagged in the README). diff --git a/aidlc-docs/_shared/reverse-engineering/business-overview.md b/aidlc-docs/_shared/reverse-engineering/business-overview.md index c220a78..2ffb0de 100644 --- a/aidlc-docs/_shared/reverse-engineering/business-overview.md +++ b/aidlc-docs/_shared/reverse-engineering/business-overview.md @@ -1,70 +1,106 @@ -# Business Overview +# Business Overview ## Business Context Diagram ```mermaid graph TD - subgraph Platform["SlpModularCms Platform"] - Identity["Identity Module\n(Auth + Users)"] - CMS["CMS Module\n(Content Mgmt)"] - Availability["Availability Module\n(System Status)"] - Core["Core / Shell\n(Domain entities, DbContext, Module I/F)"] - end + owner["Owner
(system owner)"] + admin["Administrator"] + enduser["User"] + visitor["Public website visitor"] + cms["SlpModularCms instance
(single host process)"] + slave["Other CMS instances
(slaves)"] + db[("SQL Server
database")] - Identity --> Core - CMS --> Core - Availability --> Core + owner --> cms + admin --> cms + enduser --> cms + visitor --> cms + cms --> db + cms -->|"pushes availability status"| slave + slave -->|"polls own status"| cms - Platform --> AdminFrontend["Admin Frontend\n(React SPA)"] - Platform --> ExternalClients["External Clients\n(API consumers)"] - - style Identity fill:#4CAF50,stroke:#2E7D32,color:#fff - style CMS fill:#4CAF50,stroke:#2E7D32,color:#fff - style Availability fill:#4CAF50,stroke:#2E7D32,color:#fff - style Core fill:#FFC107,stroke:#F57F17,color:#000 - style AdminFrontend fill:#2196F3,stroke:#0D47A1,color:#fff - style ExternalClients fill:#9E9E9E,stroke:#424242,color:#fff + classDef actor fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef system fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef external fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + class owner,admin,enduser,visitor actor; + class cms system; + class slave external; + class db store; ``` +Text alternative: Owners, administrators, users and public visitors all interact with a single SlpModularCms host process, which persists to SQL Server and — when acting as a Master — centrally manages the availability of other (slave) CMS instances that in turn poll it for their own status. + ## Business Description -- **Business Description**: SlpModularCms is a modular Content Management System (CMS) platform. It provides a REST API backend for managing CMS content, users, and system availability. The platform uses role-based access control (Owner, Admin, User) and supports a modular plugin architecture so that features can be added as independent modules. +- **Business Description**: SlpModularCms is a modular-monolith content management system built on .NET 10. A single deployed instance serves three surfaces from one host process: the customer's public website (`/`), the CMS administration UI (`/admin`), and the REST API (`/api/v1`). Functionality is delivered by pluggable modules discovered at startup, so one codebase can be deployed in different capability configurations. One instance can additionally take the role of **Master**, from which the system owner centrally enables or disables other ("slave") CMS instances — the commercial lever that lets the operator suspend a customer site (for example on non-payment) without needing access to that site's own hosting. + - **Business Transactions**: - - **User Authentication**: Login with email/password, receive JWT access + refresh token pair; refresh tokens for continued sessions; revoke tokens on logout. - - **System Initialization**: First-time setup — create initial Owner account before normal operations can begin. - - **User Invitation**: Admins and Owners invite new users by email; new users complete their account setup via an invitation link. - - **System Availability Management**: Owners can update the system availability status (Available / Maintenance / Unavailable); anyone can query current status. - - **CMS Content Management**: (Planned — module structure is in place but CMS-specific content modules are not yet implemented.) + + | Transaction | Description | + |---|---| + | Initial system bootstrap | On a fresh installation the first Owner account is created via a one-time setup flow; afterwards the setup endpoint reports the system as initialized. | + | Authenticate a user | A user logs in and receives a short-lived access token plus a rotating refresh token held in an httpOnly cookie; the session refreshes silently and can be revoked. | + | Change own password | An authenticated user replaces their own password. | + | Invite and onboard a user | An administrator invites a person; the invitee validates the invitation token and completes registration to become an active user. | + | Manage users | An administrator lists users, changes a user's role, activates or deactivates a user, or deletes a user. Role changes obey a hierarchy (Owner > Administrator > User). | + | Maintain own profile | Any authenticated user updates their own profile details. | + | Control local availability | An Owner switches the instance between Available, NotAvailable and Maintenance, with an optional reason shown to blocked callers. | + | Register a slave instance | On the Master an Owner adds another CMS instance by URL; the Master generates an API key, stores it encrypted and pushes the registration to that instance. | + | Centrally set a slave's status | The Owner sets a registered instance to Available, NotAvailable or Inactive on the Master; the change is pushed to the slave synchronously. | + | Reconcile slave status | A recurring integrity check on the Master re-pushes the authoritative status to every active slave, and each slave independently polls the Master for its own status — so a restarted slave or a locally tampered status self-heals. | + | Discover instance capabilities | A client asks which optional modules are loaded on this instance, so it can hide features the deployment does not have rather than showing an error. | + | Serve the public website | An anonymous visitor loads the customer's public website from the same host process that runs the API. | + - **Business Dictionary**: - - **Owner**: Highest-privilege role; can manage users, modules, and system availability. - - **Admin**: Can manage users and CMS content within their scope. - - **User**: Standard access; can use CMS features but cannot manage system settings. - - **Module**: An independently deployable feature unit that integrates into the CMS shell. - - **Invitation**: A time-limited token sent to a new user allowing them to create their account. - - **Availability Status**: Available | Maintenance | Unavailable — represents the operational state of the system. + + | Term | Meaning | + |---|---| + | **Module** | A self-contained functional unit implementing `IModule`, discovered from disk at startup. Determines what a deployed instance can do. | + | **Master** | An instance running the Master module, from which the availability of other instances is centrally managed. | + | **Slave** | An instance whose availability is (partly) controlled by a Master. Technically any instance running the Availability module that holds a Master registration. | + | **Availability status** | Whether an instance serves requests: `Available`, `NotAvailable`, or `Maintenance`. | + | **CMS instance status** | The Master's view of a registered instance: `Available`, `NotAvailable`, or `Inactive` (no longer managed by the Master; the gate is released). | + | **Master gate** | The check that blocks requests when the Master has marked this instance unavailable — separate from, and in addition to, the instance's own local availability switch. | + | **Fail-open** | Safety rule: if a slave cannot reach its Master for longer than a configured window, it reverts to Available, so an unreachable Master can never permanently block a site. | + | **Admin bypass** | An Owner or Administrator bearer token passes the availability gate, so administrators can always reach the system to switch it back on. | + | **Capability** | A module present on this instance, exposed so clients can distinguish "feature absent in this deployment" from "error". | + | **Owner / Administrator / User** | The hierarchical roles; a higher role satisfies every requirement of a lower one. | + | **Invitation** | A time-limited token allowing a named person to create an account. | + | **Public website** | The customer-facing site served at `/`. Built and deployed separately; **not part of this repository**. | + | **Admin SPA** | The CMS administration single-page application served at `/admin`, built from `frontend/`. | ## Component Level Business Descriptions -### SlpModularCms.Api -- **Purpose**: ASP.NET Core Web API host — the entry point for all HTTP requests. -- **Responsibilities**: Bootstraps the application, registers modules, configures middleware (auth, CORS, Swagger), exposes REST endpoints. +### SlpModularCms.Api (host / Client) +- **Purpose**: The deployable application. Boots the module system and serves all three surfaces — public website, admin SPA and API — from one process. +- **Responsibilities**: Compose configuration; discover and activate modules; serve static files and per-path SPA fallbacks; expose the API under a single `/api/v1` prefix; build and embed the admin SPA at publish time. + +### SlpModularCms.Api.Slave (host / Client) +- **Purpose**: A second host representing an instance **without** the Master module, so master↔slave behaviour can be exercised locally. +- **Responsibilities**: Same as the Api host minus central management; deliberately references only Core, Identity and Availability. ### SlpModularCms.Core -- **Purpose**: Shared domain core — entities, DbContext, interfaces, services, and migrations. -- **Responsibilities**: Defines domain entities (ApplicationUser, ApplicationRole, Invitation, RefreshToken, GlobalAvailabilityState), persistence (EF Core + SQL Server), and shared service contracts. +- **Purpose**: The shared foundation every module builds on. +- **Responsibilities**: Identity, authentication and hierarchical authorization; the module contract and orchestrator; the availability contract; uniform RFC 9457 error responses; the `/api/v1` routing convention; capability reporting. ### SlpModularCms.Modules.Identity -- **Purpose**: Authentication and user management module. -- **Responsibilities**: Implements AuthController (login/refresh/revoke), SetupController (initial owner creation), UsersController (invite, complete-setup, validate-invitation). +- **Purpose**: Exposes account and access management to clients. +- **Responsibilities**: Login/refresh/revoke and password change; first-Owner setup; invitations; user administration. ### SlpModularCms.Modules.Availability -- **Purpose**: System availability / health status module. -- **Responsibilities**: Implements AvailabilityController (get status, update status), caches status in-memory with circuit breaker, persists status changes to the database. +- **Purpose**: Decides whether this instance serves requests, honouring both the local switch and the Master's verdict. +- **Responsibilities**: Persist local availability; hold the Master registration; enforce the gate as middleware with documented bypasses; poll the Master for its own status; fail open when the Master is unreachable. -### SlpModularCms.Core.Tests -- **Purpose**: Unit tests for the Core layer. -- **Responsibilities**: Tests for exception classes, invitation service logic, identity services. +### SlpModularCms.Modules.Master +- **Purpose**: Turns an instance into the central control point for other instances. +- **Responsibilities**: Register instances and issue encrypted API keys; push status changes to slaves; answer a slave's status poll; reconcile periodically so drift and restarts self-heal. -### SlpModularCms.Modules.Availability.Tests -- **Purpose**: Unit/integration tests for the Availability module. -- **Responsibilities**: Tests for availability service logic and controller behavior. +### frontend (admin SPA) +- **Purpose**: The web UI through which Owners, Administrators and Users operate the CMS. +- **Responsibilities**: Login and silent session refresh; dashboard; user and invitation management; profile; availability settings (locked when the Master controls it); Master instance management; capability-driven feature gating. + +### Test projects +- **Purpose**: Protect the business rules above against regression. +- **Responsibilities**: `SlpModularCms.Core.Tests`, `SlpModularCms.Modules.Identity.Tests`, `SlpModularCms.Modules.Availability.Tests` and `SlpModularCms.Modules.Master.Tests` mirror the production projects; the admin SPA has its own Vitest suite. `SlpModularCms.Api.Slave` has no test project by design. diff --git a/aidlc-docs/_shared/reverse-engineering/code-quality-assessment.md b/aidlc-docs/_shared/reverse-engineering/code-quality-assessment.md index b8d896d..dc27920 100644 --- a/aidlc-docs/_shared/reverse-engineering/code-quality-assessment.md +++ b/aidlc-docs/_shared/reverse-engineering/code-quality-assessment.md @@ -1,34 +1,115 @@ -# Code Quality Assessment +# Code Quality Assessment + +All figures below were **measured** during this analysis (2026-07-27) rather than inferred. + +## Build + +`dotnet build SlpModularCms.sln -c Release` — **succeeds**: 0 errors, 50 warnings, ~27s. + +Warning categories: +- **NU1903 — known high-severity vulnerabilities in transitive packages** (the majority of the 50). Confirmed by `dotnet list package --vulnerable --include-transitive`: + - `Microsoft.OpenApi` **2.0.0** — GHSA-v5pm-xwqc-g5wc (High) + - `System.Security.Cryptography.Xml` **10.0.9** — GHSA-cvvh-rhrc-wg4q and four further advisories (High) + Both arrive transitively (OpenAPI tooling; Data Protection's XML key handling). A CI gate on `dotnet list package --vulnerable` would fail today until these are pinned to patched versions. +- **NU1510 — redundant `PackageReference`s** that will not be pruned: `Microsoft.Extensions.Logging.Abstractions` (Core, Modules.Identity.Tests), `Microsoft.Extensions.Hosting.Abstractions` (Core.Tests). Cosmetic. + +No C# compiler warnings — nullable reference types are respected throughout. ## Test Coverage -- **Overall**: Fair — unit tests exist for Core and Availability modules -- **Unit Tests**: Present for Core.Tests and Modules.Availability.Tests -- **Integration Tests**: Not observed in current structure -- **Frontend Tests**: None (example app has no test files) + +### Backend — all suites pass + +| Suite | Tests | Result | Duration | +|---|---|---|---| +| `SlpModularCms.Core.Tests` | 54 | ✅ all passed | 0.9s | +| `SlpModularCms.Modules.Identity.Tests` | 37 | ✅ all passed | 1.0s | +| `SlpModularCms.Modules.Availability.Tests` | 78 | ✅ all passed | 0.5s | +| `SlpModularCms.Modules.Master.Tests` | 50 | ✅ all passed | 0.6s | +| **Total** | **219** | **0 failed, 0 skipped** | ~3s | + +### Frontend — all suites pass + +`pnpm test` (Vitest): **34 test files, 213 tests, all passed**, ~39s. Every page, API hook and interactive component has a colocated test; MSW supplies request mocking per domain. + +### Coverage posture + +- **Unit tests**: Good and genuinely broad — controllers, services, repositories and background services are all covered on the backend; pages, hooks and dialogs on the frontend. +- **Integration tests**: **None.** There is no `WebApplicationFactory`-based suite, so the composed pipeline is never exercised end to end. The things that only exist in composition are therefore untested: middleware ordering, the availability gate's real interaction with static files, the `/api/v1` prefix convention, the two SPA fallbacks, CORS, rate limiting, and JWT validation against real configuration. +- **Contract tests**: **None** for the master↔slave protocol. Both sides are unit-tested in isolation with mocks, so a change to one side's contract would not be caught. +- **End-to-end tests**: None. +- **Coverage numbers**: `coverlet.runsettings` is configured (excluding migrations, `obj/`, generated OpenAPI interceptors and `[ExcludeFromCodeCoverage]` members), and the frontend has a `test:coverage` script with v8, but **no threshold is enforced anywhere** — nothing fails a build for dropping coverage. ## Code Quality Indicators -- **Linting**: Not explicitly configured (no .editorconfig or eslint config seen in backend; frontend likely uses Vite defaults) -- **Code Style**: Consistent — clean C# with XML doc comments on public interfaces and entities -- **Documentation**: Good for core interfaces and entities (XML doc comments); controllers have minimal comments -- **Naming**: Follows .NET conventions (PascalCase classes/methods, camelCase parameters) + +- **Backend linting**: Nothing beyond compiler nullable warnings — no `.editorconfig`, no analyzer package, no format check. `dotnet format --verify-no-changes` is not wired up anywhere. +- **Frontend linting**: ESLint 10 with `typescript-eslint`, `react-hooks` and `react-refresh` plugins, plus Prettier with `format:check`. **`pnpm run lint` currently FAILS** — see Technical Debt below. This is a blocking fact for any CI pipeline that runs lint as a gate. +- **Type checking**: `tsc -b` runs as part of `pnpm build`, so type errors do fail the frontend build. +- **Code style**: Consistent within each side. Backend uses XML doc comments on interfaces, entities and non-obvious services; several comments explain *why* rather than *what* (the `nonfile` constraint, the reason `build-production` is a separate job in the reference project, the master-gate bypass rationale). Frontend is Prettier-formatted with 4-space indent. +- **Comment language**: **Mixed Dutch and English** in the C# codebase — `ModuleOrchestrator` logs and doc comments are Dutch, most newer code is English, and some user-facing strings are Dutch (`AvailabilityMiddleware`'s 503 detail, `SetupController`'s success message). Not a defect, but it means user-visible API messages are Dutch-only with no localisation path, while the frontend is fully i18n'd (NL/EN). +- **Documentation**: Strong. `README.md` is thorough and current (including the single-host model and production setup); `CLAUDE.md`/`AGENTS.md`/`.junie/guidelines.md`/`.github/copilot-instructions.md` document the solution layout; `aidlc-docs/` holds the full AI-DLC history per feature. +- **Naming**: Follows .NET and React conventions consistently. +- **Reproducibility**: `frontend/pnpm-lock.yaml` exists and publish uses `--frozen-lockfile`. **No `packages.lock.json` for any .NET project**, so NuGet restore is not locked. ## Technical Debt -- Auth context in example React app uses `localStorage` for user state (security concern — no httpOnly cookies) -- Example app auth-context simulates login locally without real API calls (will need to be replaced with actual API integration) -- No CORS configuration confirmed in backend (needs verification for SPA integration) -- `AvailabilityController.UpdateStatus` uses a direct service cast (`as PersistentAvailabilityService`) which couples controller to implementation -- No OpenAPI/Swagger spec currently integrated (would help frontend integration) + +### Blocking for CI as it stands + +1. **`pnpm run lint` fails: 5 errors, 1 warning.** Any workflow that gates on lint will go red on the current `master`: + - `src/components/cms/AddCmsInstanceDialog.tsx:55` — `setState` called synchronously inside an effect (`react-hooks/set-state-in-effect`) + - `src/components/users/InviteUserDialog.tsx:50` — same rule + - `src/components/users/InviteUserDialog.tsx:54` — variable accessed before declaration + - `src/pages/SettingsPage.tsx:40` — same `setState`-in-effect rule + - `src/components/cms/SetStatusDialog.tsx:32` — `react-refresh/only-export-components`: a non-component export shares the file + - `src/components/cms/SetStatusDialog.tsx:72` — warning: "Compilation Skipped: Use of incompatible library" + Note the tests all pass regardless — these are lint-rule violations, not observed runtime failures. +2. **Two high-severity transitive vulnerabilities** (`Microsoft.OpenApi` 2.0.0, `System.Security.Cryptography.Xml` 10.0.9). A vulnerability gate cannot be switched on until these are addressed. + +### Deployment and operations gaps + +3. **No CI/CD whatsoever** — no `.gitea/workflows/`, no build/test/deploy automation. Every deployment is manual today. +4. **No health-check endpoint.** There is nothing to point uptime monitoring at, and no existing endpoint can stand in: `Availability` and `System/capabilities` are CMS domain functionality (product on/off state and loaded-module reporting, both also serving the master↔slave protocol), not health signals. A dedicated health check — outside `/api/v1` domain routing and outside the availability gate, reporting infrastructure liveness such as process up, database reachable and migrations applied — has to be built. It is **in scope for the `gitea-deployment-workflow` feature** because the framework supplies it almost for free: `AddHealthChecks()` + `MapHealthChecks("/health")` require no package, and a database probe costs only `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` 10.0.9 plus `.AddDbContextCheck()`. `/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** (``, ``). Safe — they are not real credentials, and env vars are expected to override them — but startup fails confusingly rather than clearly if an env var is missed, since `JwtSettings:Secret` is present-but-nonsense rather than absent. + +### Design-level debt + +13. **The availability gate does not cover the public website.** Static files are served before `orchestrator.UseModules(app)` installs `AvailabilityMiddleware`, so disabling an instance blocks the API and admin routes but still serves `wwwroot/index.html` and its assets. Whether that is intended is a product decision, but it is currently implicit rather than documented, and it changes what "we disabled that customer" actually means. +14. **`AvailabilityController.UpdateStatus` casts the injected `IAvailabilityService` to `PersistentAvailabilityService`** and returns `400` if the cast fails — the controller depends on a concrete implementation. Carried over from the previous assessment; still present. +15. **Module load failures are swallowed.** `ModuleOrchestrator` logs and continues when an assembly cannot be loaded or a module cannot be instantiated. A deployment that ships a broken or missing module DLL starts up "successfully" with reduced capability rather than failing fast — hard to detect without monitoring, and directly relevant to trusting a deployment. +16. **`AvailabilityMiddleware.IsAdminBypass` reads the JWT without validating its signature** (`JwtSecurityTokenHandler.ReadJwtToken`), so anyone can craft an unsigned token carrying an `Owner` role claim and bypass the availability gate. Requests still fail authentication at protected endpoints afterwards, so this is not a privilege escalation — but it does mean the gate is bypassable by an unauthenticated caller, including on a master-disabled instance. +17. **Inconsistent persistence style**: `Modules.Master` and `Modules.Availability` use the repository pattern; `Core`'s identity services use `ApplicationDbContext` and Identity managers directly. Recognised divide between older and newer code. +18. **`Microsoft.Extensions.Http.Resilience` 9.6.0 on `net10.0` targets** — the only dependency out of step with the otherwise uniform 10.0.x line. +19. **API error and status messages are Dutch-only**, with no localisation mechanism on the backend, while the frontend is fully bilingual. ## Patterns and Anti-patterns ### Good Patterns -- Module pattern provides clear separation of concerns between features -- JWT refresh token rotation is properly implemented -- Authorization policies are well-defined (OwnerOnly, AdminOnly) -- EF Core used consistently for persistence -- Service interfaces (IAuthService, IInvitationService, ISetupService) for testability + +- **Module/plugin architecture** with reflection-based discovery — capability is a property of what is deployed, which makes the master/slave distinction a packaging concern rather than a configuration flag. +- **Push *and* pull status synchronisation with fail-open** — the master↔slave design assumes messages get lost and instances restart, and it self-heals in both directions without a broker. The fail-open rule is the right default for a commercial kill-switch. +- **`UpdateStatusResult { success, slaveContactSuccess }`** — honestly reports partial success instead of collapsing two different outcomes into one boolean. +- **JWT with refresh-token rotation**, access token in memory only, refresh cookie httpOnly and path-scoped to `/api/v1/auth`. +- **Uniform RFC 9457 `ProblemDetails`** via a global handler, mirrored by a typed `ProblemDetailsError` in the frontend client. +- **Centralised route prefixing** (`ApiPrefixConvention`) rather than repeating `api/v1` in every controller. +- **The `nonfile` route constraint** on both SPA fallbacks — missing assets still 404 instead of being handed an HTML page, which is a genuinely easy mistake to make. +- **Options pattern** used consistently for all four configuration sections. +- **Resilience pipeline** on outbound master→slave calls with jittered exponential backoff. +- **High, real test coverage** with fast suites (219 backend tests in ~3s) and MSW-based frontend tests that avoid brittle mocking. +- **Comments that explain rationale**, not mechanics — several of the trickiest decisions in the codebase are documented at the point of the decision. +- **Three-file appsettings pattern** with `appsettings.local.json` gitignored and no real secrets committed. ### Anti-patterns -- Direct implementation cast in `AvailabilityController` (should use extended interface instead) -- Example React app uses localStorage-based auth (acceptable for prototype, not production) -- Example React app `auth-context` hardcodes mock users (must be replaced with real API calls) + +- Concrete-type cast in `AvailabilityController.UpdateStatus` (item 14). +- Unvalidated JWT parsing in the availability gate's admin bypass (item 16). +- Silent module-load failure (item 15). +- Build-time environment coupling in the frontend config, forcing per-environment bundles (item 8). +- Backend project reference used purely as a deployment mechanism for module DLLs — it works and is documented, but the compile-time dependency does not reflect an actual code dependency. +- Asymmetric migration strategy across the three `DbContext` types (item 10). +- Mixed-language comments and Dutch-only user-facing API strings (items 19 and the note above). diff --git a/aidlc-docs/_shared/reverse-engineering/code-structure.md b/aidlc-docs/_shared/reverse-engineering/code-structure.md index 0db90b3..d606fd9 100644 --- a/aidlc-docs/_shared/reverse-engineering/code-structure.md +++ b/aidlc-docs/_shared/reverse-engineering/code-structure.md @@ -1,119 +1,253 @@ -# Code Structure +# Code Structure ## Build System -- **Type**: .NET SDK (MSBuild / dotnet CLI) -- **Configuration**: `SlpModularCms.sln` — solution file referencing all projects -- **Target Framework**: `net10.0` + +- **Type**: .NET SDK (MSBuild / `dotnet` CLI) for the backend; pnpm + Vite for the admin SPA. +- **Solution**: `SlpModularCms.sln` — 10 projects, organised into three top-level Solution Folders (see `CLAUDE.md` / `AGENTS.md`): + - **Application** — `SlpModularCms.Core` plus a nested **Modules** folder (`Modules.Master`, `Modules.Identity`, `Modules.Availability`) + - **Tests** — mirrors Application, with its own nested **Modules** folder + - **Clients** — the deployable hosts: `SlpModularCms.Api`, `SlpModularCms.Api.Slave` +- **Target framework**: `net10.0` for every project. SDK in use: 10.0.301. +- **Key build settings**: `Nullable` and `ImplicitUsings` enabled everywhere. `SlpModularCms.Core` uses `` so a class library can depend on ASP.NET Core types. +- **Coverage**: `coverlet.runsettings` at the repository root excludes migrations, `obj/`, generated OpenAPI interceptors, and anything marked `[ExcludeFromCodeCoverage]`. +- **Frontend build**: `frontend/package.json` — `build` runs `tsc -b && vite build`; `vite.config.ts` sets `base: '/admin/'` for `command === 'build'` only, so the dev server still serves from `/`. +- **Publish coupling**: `SlpModularCms.Api.csproj` defines the `BuildAndCopyAdminFrontend` target with `BeforeTargets="Publish"`, which runs `pnpm install --frozen-lockfile` and `pnpm build` in `frontend/` and copies `frontend/dist/**` into `wwwroot/admin/`. **`dotnet publish` therefore requires Node and pnpm on the build machine.** `wwwroot/` is gitignored. ## Project Structure ```mermaid graph TD - Root["SlpModularCms/"] - Src["src/"] - Api["SlpModularCms.Api\n(API host)"] - ApiExt["Extensions/\nServiceCollectionExtensions.cs"] - ApiInfra["Infrastructure/\nGlobal exception handler"] - ApiProg["Program.cs\nApp startup + module loading"] + root["SlpModularCms (repo root)"] + sln["SlpModularCms.sln"] + src["src/"] + fe["frontend/ (admin SPA)"] + docs["aidlc-docs/"] - Core["SlpModularCms.Core\n(Shared core)"] - CoreAvail["Availability/\nAvailabilityOptions.cs\nAvailabilityStatus.cs"] - CoreData["Data/\nApplicationDbContext.cs"] - CoreIdentity["Identity/\nEntities, Models, Services\nAuthorization/"] - CoreMigrations["Migrations/\nEF Core migrations"] - CoreModules["Modules/\nIModule.cs, ModuleInfo.cs"] + api["SlpModularCms.Api
Client / host"] + slave["SlpModularCms.Api.Slave
Client / host"] + core["SlpModularCms.Core
shared library"] + mid["Modules.Identity"] + mav["Modules.Availability"] + mma["Modules.Master"] + tests["4 test projects
Core, Identity, Availability, Master"] - ModIdentity["SlpModularCms.Modules.Identity\n(Identity module)"] - ModIdentityCtrl["Controllers/\nAuthController\nSetupController\nUsersController"] + root --> sln + root --> src + root --> fe + root --> docs + src --> api + src --> slave + src --> core + src --> mid + src --> mav + src --> mma + src --> tests - ModAvail["SlpModularCms.Modules.Availability\n(Availability module)"] - ModAvailCtrl["Controllers/\nAvailabilityController"] - ModAvailSvc["Services/\nPersistentAvailabilityService"] - - Tests1["SlpModularCms.Core.Tests"] - Tests2["SlpModularCms.Modules.Availability.Tests"] - Docs["aidlc-docs/\nAI-DLC workflow documentation"] - - Root --> Src - Root --> Docs - Src --> Api - Src --> Core - Src --> ModIdentity - Src --> ModAvail - Src --> Tests1 - Src --> Tests2 - Api --> ApiExt - Api --> ApiInfra - Api --> ApiProg - Core --> CoreAvail - Core --> CoreData - Core --> CoreIdentity - Core --> CoreMigrations - Core --> CoreModules - ModIdentity --> ModIdentityCtrl - ModAvail --> ModAvailCtrl - ModAvail --> ModAvailSvc - - style Api fill:#4CAF50,stroke:#2E7D32,color:#fff - style ApiExt fill:#4CAF50,stroke:#2E7D32,color:#fff - style ApiInfra fill:#4CAF50,stroke:#2E7D32,color:#fff - style ApiProg fill:#4CAF50,stroke:#2E7D32,color:#fff - style Core fill:#FFC107,stroke:#F57F17,color:#000 - style CoreAvail fill:#FFC107,stroke:#F57F17,color:#000 - style CoreData fill:#FFC107,stroke:#F57F17,color:#000 - style CoreIdentity fill:#FFC107,stroke:#F57F17,color:#000 - style CoreMigrations fill:#FFC107,stroke:#F57F17,color:#000 - style CoreModules fill:#FFC107,stroke:#F57F17,color:#000 - style ModIdentity fill:#4CAF50,stroke:#2E7D32,color:#fff - style ModIdentityCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff - style ModAvail fill:#4CAF50,stroke:#2E7D32,color:#fff - style ModAvailCtrl fill:#4CAF50,stroke:#2E7D32,color:#fff - style ModAvailSvc fill:#4CAF50,stroke:#2E7D32,color:#fff - style Tests1 fill:#9E9E9E,stroke:#424242,color:#fff - style Tests2 fill:#9E9E9E,stroke:#424242,color:#fff - style Docs fill:#CE93D8,stroke:#6A1B9A,color:#000 + classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000; + classDef meta fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + class api,slave,fe client; + class core corelayer; + class mid,mav,mma module; + class tests test; + class root,sln,src,docs meta; ``` +Text alternative: The repository root holds the solution file, a `src/` folder with two host projects, Core, three modules and four test projects, plus a separate `frontend/` admin SPA and the `aidlc-docs/` documentation tree. + ## Key Classes/Modules -### Core Domain Entities -- `ApplicationUser` — extends `IdentityUser` with `IsActive`, `CreatedAt`, `Naam` -- `ApplicationRole` — extends `IdentityRole` -- `RefreshToken` — linked to user; has `Token`, `ExpiryDate`, `IsRevoked`, `IsActive` -- `Invitation` — linked to user (invitee); has `Token`, `ExpiryDate`, `IsUsed`, `Role` -- `GlobalAvailabilityState` — singleton-ish entity storing `Status`, `Message`, `LastUpdatedAt`, `UpdatedBy` +```mermaid +classDiagram + class IModule { + +string Name + +string Version + +RegisterServices(IServiceCollection) + +UseModule(IApplicationBuilder) + } + class ModuleOrchestrator { + +IReadOnlyList~string~ ModuleNames + +DiscoverModules() + +RegisterModuleServices(IServiceCollection) + +UseModules(IApplicationBuilder) + } + class IdentityModule + class AvailabilityModule + class MasterModule + class IAvailabilityService { + +IsAvailableAsync() + +UpdateStatusAsync() + } + class PersistentAvailabilityService + class IMasterAvailabilityService { + +GetMasterStatus() + } + class MasterAvailabilityService -### Core Services -- `IAuthService` / `AuthService` — `AuthenticateAsync`, `RefreshTokenAsync`, `RevokeTokenAsync` -- `IInvitationService` / `InvitationService` — `CreateInvitationAsync`, `CompleteInvitationAsync`, `ValidateInvitationAsync` -- `ISetupService` / `SetupService` — `IsSystemInitializedAsync`, `CreateInitialOwnerAsync` -- `IAvailabilityService` / `PersistentAvailabilityService` — `IsAvailableAsync`, `UpdateStatusAsync` + IModule <|.. IdentityModule + IModule <|.. AvailabilityModule + IModule <|.. MasterModule + ModuleOrchestrator --> IModule + IAvailabilityService <|.. PersistentAvailabilityService + IMasterAvailabilityService <|.. MasterAvailabilityService +``` -### Module System -- `IModule` — interface: `RegisterServices(IServiceCollection)`, `UseModule(IApplicationBuilder)` -- Modules discovered at startup and invoked in sequence +Text alternative: `ModuleOrchestrator` works against the `IModule` contract implemented by the three modules; the Availability module supplies the concrete availability and master-gate services behind Core's interfaces. + +### Existing Files Inventory + +#### Clients (deployable hosts) + +- `src/SlpModularCms.Api/Program.cs` — Host composition: local settings overlay, module discovery, core infrastructure/CORS/rate limiting, `/api/v1` convention, enum-as-string JSON, exception handler, HTTPS redirect, static files, CORS, module middleware, auth, controllers, and the two SPA fallbacks (`/admin/{*path:nonfile}` → `admin/index.html`, `{*path:nonfile}` → `index.html`). +- `src/SlpModularCms.Api/Program.Coverage.cs` — Coverage-support partial. +- `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — Package/project references plus the `BuildAndCopyAdminFrontend` publish target. +- `src/SlpModularCms.Api/appsettings.json` — Production baseline with placeholder secrets; `Logging` default `Warning`; `AllowedHosts: "*"`; empty `Cors:AllowedOrigins`; `JwtSettings`, `Availability`, `MasterModule`, `MasterPolling`, `RateLimiting` sections. +- `src/SlpModularCms.Api/appsettings.Development.json` — LocalDB connection, dev JWT secret, `CookieSameSite: None`, CORS for `localhost:5173`, relaxed rate limits, `MasterUrl: https://localhost:7221`. +- `src/SlpModularCms.Api/appsettings.local.json` — Gitignored developer overrides. +- `src/SlpModularCms.Api/Properties/launchSettings.json` — `http` (5284) and `https` (7221) profiles, both `Development`, launching `/scalar`. +- `src/SlpModularCms.Api.Slave/Program.cs`, `…/appsettings*.json`, `…/Properties/launchSettings.json` — Second host on 7222; no Master module reference; expects its own database. + +#### Core + +- `src/SlpModularCms.Core/Hosting/ModuleOrchestrator.cs` — Globs `SlpModularCms.Modules.*.dll` from `AppDomain.CurrentDomain.BaseDirectory`, loads assemblies, instantiates every non-abstract `IModule`, and exposes `ModuleNames`. Failures are logged, not thrown. +- `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` — `AddCoreInfrastructure` (DbContext, Identity with password policy, JWT bearer with `ClockSkew.Zero`, the three hierarchical policies, exception handler + ProblemDetails, API versioning, OpenAPI), `AddCmsCors`, `AddCmsRateLimiting` (fixed-window `login`, sliding-window `refresh`). +- `src/SlpModularCms.Core/Hosting/ApiPrefixConvention.cs` — Applies the single `api/v1` prefix to every controller. +- `src/SlpModularCms.Core/Hosting/SystemController.cs` — `GET /api/v1/System/capabilities`, returns loaded module names. +- `src/SlpModularCms.Core/Data/ApplicationDbContext.cs` — Identity + `RefreshToken`, `Invitation`, `ModulePermission`, `GlobalAvailabilityState`. +- `src/SlpModularCms.Core/Identity/Entities/*.cs` — `ApplicationUser`, `ApplicationRole`, `RefreshToken`, `Invitation`, `ModulePermission`, `GlobalAvailabilityState`. +- `src/SlpModularCms.Core/Identity/Models/*.cs` — `IdentityRequests`, `JwtSettings`, `TokenResponse`. +- `src/SlpModularCms.Core/Identity/Services/{AuthService,IAuthService,InvitationService,IInvitationService,SetupService}.cs` — Login/refresh/revoke with token rotation, invitation lifecycle, first-Owner bootstrap. +- `src/SlpModularCms.Core/Identity/Authorization/{HierarchicalRoleHandler,HierarchicalRoleRequirement}.cs` — Owner > Administrator > User satisfaction. +- `src/SlpModularCms.Core/Availability/{AvailabilityOptions,AvailabilityStatus,AvailabilityStatusDetails,IAvailabilityService,MasterControlledAvailabilityException}.cs` — Availability contract; the exception is what turns a local override attempt into `409 Conflict` while master-controlled. +- `src/SlpModularCms.Core/Exceptions/{GlobalExceptionHandler,ValidationException,UnauthorizedException,InvitationOrUserAlreadyExistsException}.cs` — RFC 9457 mapping. +- `src/SlpModularCms.Core/Modules/{IModule,ModuleInfo}.cs` — Module contract. +- `src/SlpModularCms.Core/Migrations/` — 5 files; `ApplicationDbContext` migrations, applied **manually only**. + +#### Modules.Identity + +- `Controllers/AuthController.cs` — `login` (rate-limited `login`), `refresh` (rate-limited `refresh`), `revoke`, `change-password`. +- `Controllers/SetupController.cs` — `status`, `owner`. +- `Controllers/InvitationController.cs` — `validate`, `complete`; anonymous. +- `Controllers/UsersController.cs` — list, `me` update, `invite`, role/active updates, delete; `AdminOnly` by default. +- `IdentityModule.cs` — Module registration. + +#### Modules.Availability + +- `AvailabilityModule.cs` — Registers services, `AvailabilityDbContext`, Data Protection, polling `HttpClient` and hosted service; on `UseModule` runs `Database.Migrate()` and installs `AvailabilityMiddleware`. +- `Middleware/AvailabilityMiddleware.cs` — Bypass prefixes, admin-token bypass, master gate then local status, 503 `ProblemDetails` otherwise. +- `Services/PersistentAvailabilityService.cs` — Persisted local status with caching/circuit breaker; throws `MasterControlledAvailabilityException` on local override while master-controlled. +- `Services/{MasterAvailabilityService,MasterGateStatus,MasterStatusPollClient,MasterApiKeyProtector,…}.cs` — Master gate state, poll client, encrypted key handling, DI dependency bundle. +- `BackgroundServices/MasterStatusPollingBackgroundService.cs` — Periodic pull with fail-open. +- `Controllers/{AvailabilityController,MasterController}.cs` — Public status + Owner-only update; inbound master register/status/registered-url. +- `Data/AvailabilityDbContext.cs`, `Data/Entities/MasterRegistration.cs`, `Config/MasterPollingOptions.cs`, `Repositories/*`, `Models/MasterModels.cs`; `Migrations/` — 5 files, auto-applied. + +#### Modules.Master + +- `MasterModule.cs` — Data Protection, `MasterModuleOptions`, `MasterDbContext`, repositories/services, `SlaveApiClient` with a `slave-resilience` handler (2 retries, exponential backoff with jitter, configurable timeout), `IntegrityCheckBackgroundService`, `HttpContextAccessor`; migrates on `UseModule`. +- `Controllers/CmsInstanceController.cs` — Owner-only list/create/update-status. +- `Controllers/SlaveStatusController.cs` — Anonymous pull endpoint for slaves. +- `Services/{CmsInstanceService,SlaveApiClient,ApiKeyProtector,MasterServiceDependencies,…}.cs` +- `BackgroundServices/IntegrityCheckBackgroundService.cs` — Periodic reconciliation and status re-push. +- `Data/MasterDbContext.cs`, `Data/Entities/{CmsInstance,CmsInstanceStatus}.cs`, `Models/*`, `Options/MasterModuleOptions.cs`, `Repositories/*`; `Migrations/` — 3 files, auto-applied. + +#### Tests + +- `src/SlpModularCms.Core.Tests/` — 7 files (Exceptions, Hosting, Identity). +- `src/SlpModularCms.Modules.Identity.Tests/` — 4 files (Controllers). +- `src/SlpModularCms.Modules.Availability.Tests/` — 10 files (Controllers, Services, Repositories, BackgroundServices). +- `src/SlpModularCms.Modules.Master.Tests/` — 7 files (Controllers, Services, Repositories, BackgroundServices). + +#### frontend (admin SPA) + +- `frontend/vite.config.ts` — `base: '/admin/'` on build, `@` alias, dev port 5173, Vitest config with v8 coverage. +- `frontend/package.json` — Scripts `dev`, `dev:slave` (mode `slave`, port 5174), `dev:all` (concurrently), `build`, `lint`, `format`, `format:check`, `test`, `test:watch`, `test:coverage`, `preview`. +- `frontend/.env.example` — `VITE_API_BASE_URL`, `VITE_APP_TITLE`, plus notes for the slave setup. `.env.local` / `.env.slave.local` are local-only. +- `frontend/src/lib/config.ts` — Reads `VITE_API_BASE_URL` and `VITE_APP_TITLE`; Zod-validates `apiBaseUrl` as a URL, warning only in dev. **Makes the production bundle environment-specific.** +- `frontend/src/lib/api-client.ts` — `fetch` wrapper: credentials always sent, in-memory access token, 401 refresh-and-retry interceptor, `ProblemDetailsError` and `NetworkError`. +- `frontend/src/router.tsx` — TanStack Router with `basepath: import.meta.env.BASE_URL`, so routing follows the `/admin/` base automatically. +- `frontend/src/main.tsx` — Sets document title from config, React Query client, optional MSW via `VITE_ENABLE_MSW`, renders only after the initial silent refresh settles. +- `frontend/src/api/use*.ts` — Typed hooks per resource (availability, users, profile, invitation, setup, CMS instances, system capabilities), each with a colocated test. +- `frontend/src/pages/` — 10 pages, each with a test. +- `frontend/src/components/{auth,cms,layout,shared,ui,users}/` — Guards (`ModuleGuard`, `RoleGuard`), CMS instance dialogs/list, layout shell, shadcn-style primitives. +- `frontend/src/{contexts,hooks,i18n,mocks,test}/` — Auth provider, hooks, NL/EN translations, MSW handlers per domain, test setup. ## Design Patterns -### Module Pattern -- **Location**: `SlpModularCms.Core/Modules/`, `SlpModularCms.Api/Program.cs` -- **Purpose**: Allows features to be developed, tested, and deployed independently -- **Implementation**: Each module class implements `IModule` and is registered in the API host -### Repository Pattern via EF Core -- **Location**: `ApplicationDbContext` used directly in services -- **Purpose**: Centralized persistence with Entity Framework +### Module / plugin pattern +- **Location**: `Core/Modules/IModule.cs`, `Core/Hosting/ModuleOrchestrator.cs`, each `*Module.cs`. +- **Purpose**: Let one codebase deploy with different capability sets. +- **Implementation**: Reflection-based discovery of `SlpModularCms.Modules.*.dll` in the app base directory; each module registers services and middleware itself. Deployment content, not configuration, decides capability. -### JWT with Refresh Token Rotation -- **Location**: `AuthService.cs`, `AuthController.cs` -- **Purpose**: Stateless auth with token refresh capability +### Repository pattern +- **Location**: `Modules.Master/Repositories/`, `Modules.Availability/Repositories/`. +- **Purpose**: Keep EF Core access behind an interface so services stay unit-testable. +- **Implementation**: Interface plus EF-backed implementation per aggregate. Core's identity services use `ApplicationDbContext`/Identity managers directly rather than repositories — an intentional inconsistency between old and new code. + +### Options pattern +- **Location**: `JwtSettings`, `AvailabilityOptions`, `MasterModuleOptions`, `MasterPollingOptions`. +- **Purpose**: Bind configuration sections to typed objects. +- **Implementation**: `services.Configure` / `AddOptions().BindConfiguration(...)`. + +### Dependency-bundle (parameter object) +- **Location**: `MasterServiceDependencies`, `MasterAvailabilityServiceDependencies`. +- **Purpose**: Keep constructors manageable where a service needs many collaborators. + +### Middleware gate +- **Location**: `AvailabilityMiddleware`. +- **Purpose**: Enforce availability centrally rather than per controller, with explicit bypasses. + +### Background reconciliation (push + pull) +- **Location**: `IntegrityCheckBackgroundService` (master push), `MasterStatusPollingBackgroundService` (slave pull, fail-open). +- **Purpose**: Make distributed status self-healing without a message broker. + +### JWT with refresh-token rotation +- **Location**: `AuthService`, `AuthController`, `frontend/src/lib/api-client.ts`. +- **Purpose**: Short-lived access tokens held in memory; rotating refresh token in an httpOnly cookie scoped to `/api/v1/auth`. + +### Global exception handling to RFC 9457 +- **Location**: `GlobalExceptionHandler` + typed exceptions. +- **Purpose**: One error contract for all clients. + +### Resilience pipeline +- **Location**: `MasterModule` `slave-resilience` handler. +- **Purpose**: Tolerate slow or briefly unreachable slaves without failing the Owner's action outright. ## Critical Dependencies -### ASP.NET Core Identity -- **Version**: .NET 10 built-in -- **Usage**: User/Role management, password hashing -- **Purpose**: Provides authentication primitives -### Entity Framework Core -- **Version**: .NET 10 built-in -- **Usage**: Data persistence with SQL Server provider -- **Purpose**: ORM for all domain entities +### Microsoft.EntityFrameworkCore.SqlServer — 10.0.9 +- **Usage**: All three `DbContext` types, one shared connection string. +- **Purpose**: Persistence. Module contexts self-migrate; the Core context does not. + +### Microsoft.AspNetCore.Identity.EntityFrameworkCore — 10.0.9 +- **Usage**: `ApplicationUser`/`ApplicationRole` stores, password hashing, policy. +- **Purpose**: Account primitives. + +### Microsoft.AspNetCore.Authentication.JwtBearer — 10.0.9 +- **Usage**: Token validation in `AddCoreInfrastructure` with `ClockSkew.Zero`. +- **Purpose**: Stateless authentication. Requires `JwtSettings:Secret` to be present or startup throws. + +### ASP.NET Core Data Protection (shared framework) +- **Usage**: `ApiKeyProtector`, `MasterApiKeyProtector`. +- **Purpose**: Encrypt slave API keys at rest. **Default file-system key ring with no persistent store configured** — a redeploy that loses the key folder makes stored keys unreadable. + +### Microsoft.Extensions.Http.Resilience — 9.6.0 +- **Usage**: `SlaveApiClient`. +- **Purpose**: Retry and timeout for master→slave calls. Note: 9.x package on a `net10.0` target. + +### Asp.Versioning.Mvc — 10.0.0 +- **Usage**: `AddApiVersioning` with `ReportApiVersions`. +- **Purpose**: Version reporting alongside the static `/api/v1` prefix convention. + +### Scalar.AspNetCore — 2.16.3 +- **Usage**: `MapScalarApiReference()`, Development only. +- **Purpose**: API reference UI at `/scalar`. Not exposed in production. + +### Vite 8 + React 19 + TanStack Router/Query (frontend) +- **Usage**: Admin SPA build and runtime. +- **Purpose**: `base: '/admin/'` and `basepath: import.meta.env.BASE_URL` are what make the `/admin` mount work. + +### pnpm (build-time, backend publish) +- **Usage**: Invoked from `SlpModularCms.Api.csproj` during publish. +- **Purpose**: Builds the admin SPA. Makes Node + pnpm a hard prerequisite of `dotnet publish`. diff --git a/aidlc-docs/_shared/reverse-engineering/component-inventory.md b/aidlc-docs/_shared/reverse-engineering/component-inventory.md index e21ced3..80b5e78 100644 --- a/aidlc-docs/_shared/reverse-engineering/component-inventory.md +++ b/aidlc-docs/_shared/reverse-engineering/component-inventory.md @@ -1,23 +1,51 @@ -# Component Inventory +# Component Inventory + +Solution folders in `SlpModularCms.sln` follow the layout mandated by `CLAUDE.md` / `AGENTS.md`: **Application** (with nested **Modules**), **Tests** (mirroring Application, also with nested **Modules**), and **Clients** (the deployable projects). + +## Clients (deployable hosts) + +- `src/SlpModularCms.Api` — The production host. Serves the public website (`/`), the admin SPA (`/admin`) and the API (`/api/v1`) from one process. References Core plus all three modules. Its `.csproj` builds and embeds the admin SPA on publish. +- `src/SlpModularCms.Api.Slave` — Second host used locally to represent an instance **without** the Master module (Core + Identity + Availability only). Runs on port 7222 against its own database. ## Application Packages -- `SlpModularCms.Api` — Web API host; bootstraps application, registers modules, exposes HTTP endpoints -- `SlpModularCms.Modules.Identity` — Identity module: authentication, setup, user invitation controllers -- `SlpModularCms.Modules.Availability` — Availability module: system status tracking controllers and services -## Shared Packages -- `SlpModularCms.Core` — Core domain: entities, DbContext, services, module interface, migrations +- `src/SlpModularCms.Core` — Shared foundation: Identity entities and services, hierarchical authorization, `IModule` + `ModuleOrchestrator`, `ApiPrefixConvention`, `GlobalExceptionHandler` and typed exceptions, the availability contract, `SystemController`, and `ApplicationDbContext` with 5 migrations (applied manually only). + +### Modules (nested under Application) + +- `src/SlpModularCms.Modules.Identity` — Auth, setup, invitation and user controllers. No persistence of its own. +- `src/SlpModularCms.Modules.Availability` — The availability gate: `AvailabilityMiddleware`, `PersistentAvailabilityService`, `AvailabilityDbContext` (`MasterRegistration`, 5 migrations, self-applied), master registration/status endpoints, `MasterStatusPollingBackgroundService` with fail-open, Data 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 -- `SlpModularCms.Core.Tests` — Unit tests for Core layer (exceptions, identity services) -- `SlpModularCms.Modules.Availability.Tests` — Unit tests for Availability module -## Frontend (To Be Built) -- `SlpModularCms.Frontend` — React SPA; admin panel for CMS management +- `src/SlpModularCms.Core.Tests` — Unit (7 files): Exceptions, Hosting, Identity. +- `src/SlpModularCms.Modules.Identity.Tests` — Unit (4 files): Controllers. +- `src/SlpModularCms.Modules.Availability.Tests` — Unit (10 files): Controllers, Services, Repositories, BackgroundServices. +- `src/SlpModularCms.Modules.Master.Tests` — Unit (7 files): Controllers, Services, Repositories, BackgroundServices. +- `frontend/src/**/*.test.ts(x)` — 34 Vitest files colocated with the code under test, using Testing Library and MSW. Not a separate package. + +`SlpModularCms.Api.Slave` has no test project by design (documented in `CLAUDE.md`). `SlpModularCms.Api` has no test project either — its `Program.cs` is composition only, with a `Program.Coverage.cs` partial supporting coverage collection. ## Total Count -- **Total Packages**: 6 (5 existing .NET + 1 new frontend) -- **Application**: 3 (Api, Modules.Identity, Modules.Availability) -- **Shared**: 1 (Core) -- **Test**: 2 (Core.Tests, Modules.Availability.Tests) -- **Frontend**: 1 (to be built) + +- **Total .NET projects in the solution**: 10 +- **Clients (deployable)**: 2 — `Api`, `Api.Slave` +- **Application**: 4 — `Core`, `Modules.Identity`, `Modules.Availability`, `Modules.Master` +- **Test**: 4 — `Core.Tests`, `Modules.Identity.Tests`, `Modules.Availability.Tests`, `Modules.Master.Tests` +- **Infrastructure**: 0 +- **Non-solution packages**: 1 — `frontend/` (admin SPA) + +## Approximate Size + +- C# source files (excluding `bin`/`obj`): 122 +- Of which EF Core migration files: 13 (5 Core, 5 Availability, 3 Master) +- TypeScript/TSX files under `frontend/src`: 111, of which 34 are tests diff --git a/aidlc-docs/_shared/reverse-engineering/dependencies.md b/aidlc-docs/_shared/reverse-engineering/dependencies.md index 6194501..381d259 100644 --- a/aidlc-docs/_shared/reverse-engineering/dependencies.md +++ b/aidlc-docs/_shared/reverse-engineering/dependencies.md @@ -1,118 +1,187 @@ -# Dependencies +# Dependencies ## Internal Dependencies ```mermaid graph TD - Api["SlpModularCms.Api"] - Core["SlpModularCms.Core"] - ModIdentity["SlpModularCms.Modules.Identity"] - ModAvail["SlpModularCms.Modules.Availability"] - CoreTests["SlpModularCms.Core.Tests"] - AvailTests["SlpModularCms.Modules.Availability.Tests"] - Frontend["SlpModularCms.Frontend\n(to be built)"] + api["SlpModularCms.Api
Client"] + slave["SlpModularCms.Api.Slave
Client"] + core["SlpModularCms.Core"] + mid["Modules.Identity"] + mav["Modules.Availability"] + mma["Modules.Master"] + tcore["Core.Tests"] + tid["Modules.Identity.Tests"] + tav["Modules.Availability.Tests"] + tma["Modules.Master.Tests"] + fe["frontend
admin SPA"] - Api -->|compile| Core - Api -->|compile| ModIdentity - Api -->|compile| ModAvail - ModIdentity -->|compile| Core - ModAvail -->|compile| Core - CoreTests -->|test| Core - AvailTests -->|test| ModAvail - AvailTests -->|test| Core - Frontend -->|runtime REST| Api + api --> core + api --> mid + api --> mav + api --> mma + slave --> core + slave --> mid + slave --> mav + mid --> core + mav --> core + mma --> core + tcore --> core + tid --> mid + tav --> mav + tma --> mma + fe -->|"REST at runtime"| api + api -->|"pnpm build at publish"| fe - style Api fill:#4CAF50,stroke:#2E7D32,color:#fff - style Core fill:#FFC107,stroke:#F57F17,color:#000 - style ModIdentity fill:#4CAF50,stroke:#2E7D32,color:#fff - style ModAvail fill:#4CAF50,stroke:#2E7D32,color:#fff - style CoreTests fill:#9E9E9E,stroke:#424242,color:#fff - style AvailTests fill:#9E9E9E,stroke:#424242,color:#fff - style Frontend fill:#2196F3,stroke:#0D47A1,color:#fff + classDef client fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef corelayer fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef test fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000; + classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + class api,slave client; + class core corelayer; + class mid,mav,mma module; + class tcore,tid,tav,tma test; + class fe frontend; ``` +Text alternative: Both host projects reference Core and their modules; every module references only Core; each test project targets its own production project; the admin SPA calls the API at runtime and is itself built by the API project at publish time — a bidirectional coupling between backend and frontend. + ### Dependency Details -#### SlpModularCms.Api depends on SlpModularCms.Core +#### `SlpModularCms.Api` → `SlpModularCms.Core` - **Type**: Compile -- **Reason**: Needs ApplicationDbContext, entities, DI extensions, module registration +- **Reason**: `AddCoreInfrastructure`, `AddCmsCors`, `AddCmsRateLimiting`, `ModuleOrchestrator`, `ApiPrefixConvention`. -#### SlpModularCms.Api depends on SlpModularCms.Modules.Identity +#### `SlpModularCms.Api` → `Modules.Identity`, `Modules.Availability`, `Modules.Master` +- **Type**: Compile (present to force the module DLLs into the output directory) +- **Reason**: `ModuleOrchestrator` discovers modules by globbing `SlpModularCms.Modules.*.dll` in the app base directory, so a project reference is how a module ends up deployed. The code itself does not call into the modules directly. **Consequence for deployment: which modules an instance has is determined by which DLLs the published output contains.** + +#### `SlpModularCms.Api.Slave` → `Core`, `Modules.Identity`, `Modules.Availability` - **Type**: Compile -- **Reason**: Registers Identity module and its HTTP controllers +- **Reason**: Same as above, minus the Master module — the omission is the entire point of this host. -#### SlpModularCms.Api depends on SlpModularCms.Modules.Availability +#### `Modules.Identity` → `Core` - **Type**: Compile -- **Reason**: Registers Availability module and its HTTP controllers +- **Reason**: Identity entities, `IAuthService`, `IInvitationService`, `ISetupService`, request/response models, authorization policies. -#### SlpModularCms.Modules.Identity depends on SlpModularCms.Core +#### `Modules.Availability` → `Core` - **Type**: Compile -- **Reason**: Uses domain entities (ApplicationUser, Invitation), services (IAuthService), and DbContext +- **Reason**: `IAvailabilityService`, `AvailabilityStatus`, `AvailabilityOptions`, `MasterControlledAvailabilityException`, `IModule`. -#### SlpModularCms.Modules.Availability depends on SlpModularCms.Core +#### `Modules.Master` → `Core` - **Type**: Compile -- **Reason**: Uses GlobalAvailabilityState, AvailabilityStatus, AvailabilityOptions +- **Reason**: `IModule`, authorization policies, shared exception types. -## External Dependencies (Backend) +#### Test projects → their production project (and transitively `Core`) +- **Type**: Test +- **Reason**: Each suite mirrors one production project, per the solution-folder rules in `CLAUDE.md`. -### Microsoft.AspNetCore.Identity -- **Version**: .NET 10 built-in -- **Purpose**: User and role management, password hashing -- **License**: MIT +#### `frontend` → `SlpModularCms.Api` +- **Type**: Runtime (HTTP/REST) +- **Reason**: All data comes from `/api/v1/**` at `VITE_API_BASE_URL`, with credentials so the refresh cookie travels. -### Microsoft.EntityFrameworkCore + SqlServer provider -- **Version**: .NET 10 built-in -- **Purpose**: Data persistence -- **License**: MIT +#### `SlpModularCms.Api` → `frontend` (build-time, reverse direction) +- **Type**: Build +- **Reason**: The `BuildAndCopyAdminFrontend` MSBuild target runs `pnpm install --frozen-lockfile` and `pnpm build` in `frontend/` before publish and copies `dist/**` into `wwwroot/admin/`. **This makes Node and pnpm hard prerequisites of `dotnet publish` on any build agent.** -### Microsoft.AspNetCore.Authentication.JwtBearer -- **Version**: .NET 10 built-in -- **Purpose**: JWT authentication middleware -- **License**: MIT +### Cross-instance runtime dependencies -### Microsoft.IdentityModel.Tokens -- **Version**: .NET 10 built-in -- **Purpose**: JWT token creation and validation -- **License**: MIT +Not project references, but real coupling between deployed instances: -## External Dependencies (Frontend — from package.json) +- A **Master** instance calls each registered slave's `POST /api/v1/master/register` and `POST /api/v1/master/status`, authenticated with `X-Master-Api-Key`, through a resilience pipeline (2 retries, exponential backoff with jitter, configurable timeout). +- Each **slave** calls its Master's `GET /api/v1/SlaveStatus` on a timer (`MasterPolling:PollIntervalSeconds`), and fails open to Available after `MasterPolling:FailOpenAfterMinutes` of unreachability. +- Both directions require the two instances to be reachable over HTTP from one another, which is a deployment/network consideration rather than a code one. -### react + react-dom -- **Version**: 18.3.1 -- **Purpose**: Core UI framework -- **License**: MIT +## External Dependencies -### react-router -- **Version**: 7.13.0 -- **Purpose**: Client-side routing -- **License**: MIT +### Backend — `SlpModularCms.Core` -### @radix-ui/* (multiple packages) -- **Version**: Various (1.x–2.x) -- **Purpose**: shadcn/ui component primitives -- **License**: MIT +| Package | Version | Purpose | License | +|---|---|---|---| +| `Microsoft.EntityFrameworkCore.SqlServer` | 10.0.9 | SQL Server persistence | MIT | +| `Microsoft.AspNetCore.Identity.EntityFrameworkCore` | 10.0.9 | Users, roles, password hashing | MIT | +| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.9 | Bearer token validation | MIT | +| `Microsoft.AspNetCore.OpenApi` | 10.0.9 | OpenAPI document generation | MIT | +| `Asp.Versioning.Mvc` | 10.0.0 | API version reporting | MIT | +| `Microsoft.Extensions.DependencyInjection.Abstractions` | 10.0.9 | DI abstractions | MIT | +| `Microsoft.Extensions.Hosting.Abstractions` | 10.0.9 | Hosting abstractions | MIT | +| `Microsoft.Extensions.Logging.Abstractions` | 10.0.9 | Logging abstractions | MIT | +| `FrameworkReference: Microsoft.AspNetCore.App` | net10.0 | Lets a class library use ASP.NET Core types, incl. Data Protection | MIT | -### tailwindcss -- **Version**: 4.1.12 -- **Purpose**: Utility-first CSS framework -- **License**: MIT +### Backend — `SlpModularCms.Api` -### lucide-react -- **Version**: 0.487.0 -- **Purpose**: Icon library -- **License**: ISC +| Package | Version | Purpose | License | +|---|---|---|---| +| `Asp.Versioning.Mvc` | 10.0.0 | API versioning | MIT | +| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.9 | Bearer auth | MIT | +| `Microsoft.AspNetCore.OpenApi` | 10.0.9 | OpenAPI | MIT | +| `Microsoft.EntityFrameworkCore.Design` | 10.0.9 (PrivateAssets) | `dotnet ef` tooling | MIT | +| `Scalar.AspNetCore` | 2.16.3 | `/scalar` API reference, Development only | MIT | -### recharts -- **Version**: 2.15.2 -- **Purpose**: Charts and data visualization -- **License**: MIT +`SlpModularCms.Api.Slave` carries `Microsoft.EntityFrameworkCore.Design` and `Scalar.AspNetCore` at the same versions. -### react-hook-form -- **Version**: 7.55.0 -- **Purpose**: Form state management -- **License**: MIT +### Backend — modules -### sonner -- **Version**: 2.0.3 -- **Purpose**: Toast notifications -- **License**: MIT +| Project | Package | Version | Purpose | License | +|---|---|---|---|---| +| `Modules.Master` | `Microsoft.Extensions.Http.Resilience` | 9.6.0 | Retry/timeout for master→slave calls (pulls in Polly) | MIT | +| `Modules.Availability` | — | — | No external packages beyond Core's transitives | — | +| `Modules.Identity` | — | — | No external packages beyond Core's transitives | — | + +**Version note**: `Microsoft.Extensions.Http.Resilience` 9.6.0 is a 9.x package on `net10.0` targets. It works, but it is the one dependency out of step with the otherwise uniform 10.0.x line — worth pinning deliberately rather than by accident in any CI setup. + +### Backend — test projects (all four, identical set) + +| Package | Version | Purpose | License | +|---|---|---|---| +| `Microsoft.NET.Test.Sdk` | 17.14.1 | Test host | MIT | +| `xunit` | 2.9.3 | Test framework | Apache-2.0 | +| `xunit.runner.visualstudio` | 3.1.4 | Test adapter | Apache-2.0 | +| `FluentAssertions` | 8.10.0 | Assertions | Dual: free for non-commercial / paid commercial from v8 — **worth verifying against how this project is used** | +| `NSubstitute` | 5.3.0 | Mocking | BSD-3-Clause | +| `AutoFixture` | 4.18.1 | Test data generation | MIT | +| `Microsoft.EntityFrameworkCore.InMemory` | 10.0.9 | In-memory provider for tests | MIT | +| `coverlet.collector` | 6.0.4 | Coverage collection | MIT | + +### Frontend — runtime (`frontend/package.json` dependencies) + +| Package | Version | Purpose | License | +|---|---|---|---| +| `react`, `react-dom` | ^19.2.6 | UI framework | MIT | +| `@tanstack/react-router` | ^1.170.16 | Routing (honours `BASE_URL`, so `/admin` works) | MIT | +| `@tanstack/react-query` | ^5.101.0 | Server state | MIT | +| `@radix-ui/react-{dialog,dropdown-menu,label,select,slot}` | 1.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. diff --git a/aidlc-docs/_shared/reverse-engineering/reverse-engineering-timestamp.md b/aidlc-docs/_shared/reverse-engineering/reverse-engineering-timestamp.md index ce6d4d7..9ddc63a 100644 --- a/aidlc-docs/_shared/reverse-engineering/reverse-engineering-timestamp.md +++ b/aidlc-docs/_shared/reverse-engineering/reverse-engineering-timestamp.md @@ -1,9 +1,19 @@ -# Reverse Engineering Metadata +# Reverse Engineering Metadata -**Analysis Date**: 2026-06-16T20:30:00Z -**Analyzer**: AI-DLC (Junie) +**Analysis Date**: 2026-07-27T00:00:00Z +**Analyzer**: AI-DLC (Claude Code) **Workspace**: K:\Development\Projects\SlpModularCms -**Total Files Analyzed**: ~35 (backend .cs files) + ~60 (frontend .tsx/.ts files from ZIP) +**Git branch / HEAD at analysis**: `feature/gitea-deployment-workflow` (branched from `master` at `3885703`) +**Total Files Analyzed**: 122 C# files (excluding `bin`/`obj`) + 111 TypeScript/TSX files under `frontend/src` + solution, project, configuration and lock files + +**Trigger**: Full rerun requested by the user during the `gitea-deployment-workflow` feature. The previous artifacts (2026-06-16) predated the Master module, the `SlpModularCms.Api.Slave` host, the solution-folder reorganisation (`754bd97`) and single-host serving (`3885703`) — all deployment-relevant. + +**Verification performed** (measured, not inferred): +- `dotnet build SlpModularCms.sln -c Release` — 0 errors, 50 warnings +- `dotnet test SlpModularCms.sln -c Release` — 219 tests, all passed +- `cd frontend && pnpm test` — 34 files, 213 tests, all passed +- `cd frontend && pnpm run lint` — **fails**: 5 errors, 1 warning +- `dotnet list package --vulnerable --include-transitive` — 2 high-severity transitive advisories ## Artifacts Generated - [x] business-overview.md @@ -14,3 +24,6 @@ - [x] technology-stack.md - [x] dependencies.md - [x] code-quality-assessment.md + +## Previous Analysis +Superseded: 2026-06-16T20:30:00Z by AI-DLC (Junie), ~35 backend + ~60 frontend files. Prior versions remain retrievable from git history. diff --git a/aidlc-docs/_shared/reverse-engineering/technology-stack.md b/aidlc-docs/_shared/reverse-engineering/technology-stack.md index 6d0e9c7..d349654 100644 --- a/aidlc-docs/_shared/reverse-engineering/technology-stack.md +++ b/aidlc-docs/_shared/reverse-engineering/technology-stack.md @@ -1,56 +1,80 @@ -# Technology Stack +# Technology Stack ## Backend ### Programming Languages -- C# 14.0 — All backend packages +- C# (latest for `net10.0`) — all backend projects. `Nullable` and `ImplicitUsings` enabled everywhere. ### Frameworks -- ASP.NET Core 10.0 — Web API framework -- ASP.NET Core Identity — User/role management, password hashing -- Entity Framework Core 10.0 — ORM for SQL Server persistence +- .NET / ASP.NET Core `net10.0` — host, MVC controllers, middleware pipeline, static-file serving with SPA fallbacks. SDK observed: **10.0.301**. +- ASP.NET Core Identity (`Microsoft.AspNetCore.Identity.EntityFrameworkCore` 10.0.9) — users, roles, password hashing and policy. +- Entity Framework Core (`Microsoft.EntityFrameworkCore.SqlServer` 10.0.9, `…Design` 10.0.9) — three `DbContext` types over one connection string. +- `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.9 — bearer token validation, `ClockSkew.Zero`. +- `Asp.Versioning.Mvc` 10.0.0 — API version reporting alongside the static `/api/v1` prefix convention. +- `Microsoft.AspNetCore.OpenApi` 10.0.9 + `Scalar.AspNetCore` 2.16.3 — OpenAPI document and `/scalar` reference UI, **Development only**. +- `Microsoft.Extensions.Http.Resilience` 9.6.0 (with Polly) — retry/timeout pipeline for master→slave HTTP calls. Note: a 9.x package on a `net10.0` target. +- ASP.NET Core Data Protection (shared framework) — encrypts slave API keys. Default file-system key ring; **no persistent key store configured**. +- Built-in rate limiting (`Microsoft.AspNetCore.RateLimiting`) — fixed-window `login`, sliding-window `refresh`. ### Infrastructure -- SQL Server — Primary database -- JWT Bearer Authentication — Stateless auth with refresh tokens +- SQL Server — one database per instance. Local development via a container (`mcr.microsoft.com/mssql/server:2022-latest`) or LocalDB. +- No cloud services, message broker, cache server or container orchestration is used. +- Deployment target: shared hosting (e.g. mijnhostingpartner.nl) with a single site/application pool and **no server configuration**. The web SDK generates `web.config` on publish for IIS-based hosting. ### Build Tools -- .NET 10 SDK / dotnet CLI — Build, test, publish -- MSBuild — Underlying build engine +- .NET SDK 10 / `dotnet` CLI — build, test, publish. +- MSBuild — including the custom `BuildAndCopyAdminFrontend` target in `SlpModularCms.Api.csproj`, which makes **Node and pnpm hard prerequisites of `dotnet publish`**. +- `dotnet ef` — migration authoring; per-module contexts need `--context` disambiguation (`AvailabilityDbContext`). ### Testing Tools -- xUnit (inferred from project conventions) — Unit testing framework -- Moq or similar (inferred) — Mocking in unit tests +- xUnit 2.9.3 with `xunit.runner.visualstudio` 3.1.4 and `Microsoft.NET.Test.Sdk` 17.14.1. +- FluentAssertions 8.10.0 — assertions. +- NSubstitute 5.3.0 — mocking. +- AutoFixture 4.18.1 — test data. +- `Microsoft.EntityFrameworkCore.InMemory` 10.0.9 — in-memory persistence for tests. +- coverlet 6.0.4 (`coverlet.collector`) with `coverlet.runsettings` at the repository root. ---- - -## Frontend (Example App — ZIP file basis) +## Frontend (admin SPA, `frontend/`) ### Programming Languages -- TypeScript — All frontend code +- TypeScript `~6.0.2` — all frontend code. -### Frameworks -- React 18.3.1 — UI framework -- TanStack Router — Client-side routing (replaces React Router v7 from example app; chosen for full TypeScript safety and modern routing features) -- Tailwind CSS v4 (4.1.12) — Utility-first CSS framework -- shadcn/ui (via Radix UI) — Accessible component primitives - -### UI Component Libraries -- Radix UI — Headless component primitives (accordion, dialog, dropdown, etc.) -- lucide-react (0.487.0) — SVG icon library -- recharts (2.15.2) — Charts and data visualization -- MUI / Material UI (7.3.5) — Additional UI components - -### State / Data -- react-hook-form (7.55.0) — Form state management -- sonner (2.0.3) — Toast notifications -- next-themes (0.4.6) — Dark/light theme support +### Frameworks and Libraries +- React 19.2 + React DOM 19.2. +- Vite 8.0 with `@vitejs/plugin-react` 6 — build and dev server. `base: '/admin/'` on build only. +- TanStack Router 1.170 — routing, `basepath: import.meta.env.BASE_URL` so it follows the `/admin/` base. +- TanStack React Query 5.101 — server state. +- Tailwind CSS 4.3 via `@tailwindcss/vite` — styling. +- Radix UI primitives (dialog, dropdown-menu, label, select, slot) with shadcn-style wrappers; `class-variance-authority`, `clsx`, `tailwind-merge`. +- `lucide-react` 1.21 — icons. `sonner` 2.0 — toasts. +- `react-hook-form` 7.79 with `@hookform/resolvers` 5.4 and `zod` 4.4 — forms and validation. Zod also validates app config. +- `i18next` 26 / `react-i18next` 17 / `i18next-browser-languagedetector` 8 — NL/EN. ### Build Tools -- Vite 6.3.5 — Build tool and dev server -- pnpm — Package manager (pnpm-workspace.yaml present) -- PostCSS — CSS processing +- pnpm — package manager. Observed locally: pnpm 10.33.2, Node v22.15.1. (The README states Node 20+ and pnpm 9+ as the requirement.) +- `tsc -b` runs before `vite build`, so type errors fail the build. -### Theme -- Primary color: `#ac0000` (deep red) -- Mode: Light + dark via CSS custom properties +### Testing Tools +- Vitest 4.1 with `@vitest/coverage-v8` and jsdom 29. +- Testing Library: `@testing-library/react` 16.3, `jest-dom` 6.9, `user-event` 14.6. +- MSW 2.14 — request mocking in tests, and optionally in the browser via `VITE_ENABLE_MSW=true`. + +### Linting and Formatting +- ESLint 10 with `typescript-eslint` 8.59, `eslint-plugin-react-hooks` 7, `eslint-plugin-react-refresh` 0.5. +- Prettier 3.8 (`format`, `format:check` scripts, 4-space indent). +- No linter or analyzer configuration exists for the backend beyond compiler nullable warnings. + +## Observability + +**Not implemented.** The stack currently has: +- Logging: default ASP.NET Core console provider only, configured through `Logging:LogLevel` (`Warning` in the production baseline, `Information` in Development). No structured logging, no log sink, no correlation IDs. +- Error tracking: none — no Sentry package on either side. +- Analytics: none — no Umami script or equivalent. +- Uptime/health: no health-check endpoint, no `MapHealthChecks`. +- Metrics/tracing: no OpenTelemetry. + +The intended stack for this feature — **UptimeRobot** for uptime, **Umami** for analytics, **console logging plus Sentry** for logging and errors — is therefore entirely greenfield in this repository. A working reference implementation of the Umami and Sentry parts (for a React/Vite frontend) exists in `K:\Development\SlpSoftware\Projects\SlpSoftware`. + +## Environments + +Three environments are in scope: **local**, **test** and **production**. Configuration follows the three-file appsettings pattern (`appsettings.json`, `appsettings.Development.json`, gitignored `appsettings.local.json`), with production secrets supplied as environment variables using the `Section__Key` convention. There is currently no `appsettings.Test.json` or equivalent, and no `ASPNETCORE_ENVIRONMENT` value defined for a test environment. diff --git a/aidlc-docs/active-features.md b/aidlc-docs/active-features.md index ded4873..ebaace0 100644 --- a/aidlc-docs/active-features.md +++ b/aidlc-docs/active-features.md @@ -7,3 +7,4 @@ | 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 | | 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 | diff --git a/aidlc-docs/feature-selection.md b/aidlc-docs/feature-selection.md index e814d54..fdcf6e2 100644 --- a/aidlc-docs/feature-selection.md +++ b/aidlc-docs/feature-selection.md @@ -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 | |---|---------|--------|--------| -| 1 | SlpModularCms.Api Implementation (slp-modular-cms-api) | ✅ Complete | unknown | -| 2 | CMS Frontend (cms-frontend) | ✅ Complete | unknown | +| 1 | SlpModularCms.Api Implementation (`slp-modular-cms-api`) | ✅ 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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/application-design.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/application-design.md new file mode 100644 index 0000000..427bec9 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/application-design.md @@ -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 | diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-dependency.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-dependency.md new file mode 100644 index 0000000..431e9d6 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-dependency.md @@ -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
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
including Data Protection"] + build["Build application"] + mig["Migrate ApplicationDbContext"] + modmig["Module contexts migrate
during UseModules"] + serve["Accept traffic;
/health answers"] + dead["Process does not start;
/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
production only")] + newrel["New release directory"] + persist[("Persistent wwwroot/web
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. | diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-methods.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-methods.md new file mode 100644 index 0000000..19e9261 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/component-methods.md @@ -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 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 PathPolicies { get; set; } = new(); + public string DefaultPolicy { get; set; } = "Relaxed"; + public List AllowedScriptOrigins { get; set; } = new(); + public List 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 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 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) | diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/components.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/components.md new file mode 100644 index 0000000..0c907d8 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/components.md @@ -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` 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 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**. diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/services.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/services.md new file mode 100644 index 0000000..fa68eea --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/services.md @@ -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:
PR, push to master,
or workflow_dispatch"] + gates["Quality gates
build, test, vulnerability scan,
frontend build, test, lint"] + buildtest["Build artifact: test
env-specific Vite vars"] + buildprod["Build artifact: production
env-specific Vite vars"] + deploytest["deploy-scp: test
auto on master"] + deployprod["deploy-scp: production
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. diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-dependency.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-dependency.md new file mode 100644 index 0000000..890ef50 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-dependency.md @@ -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. diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-story-map.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-story-map.md new file mode 100644 index 0000000..96f12e3 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work-story-map.md @@ -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. diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work.md b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work.md new file mode 100644 index 0000000..77bfc16 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/unit-of-work.md @@ -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` +- **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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/plans/application-design-plan.md b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/application-design-plan.md new file mode 100644 index 0000000..8dccc90 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/application-design-plan.md @@ -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` 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://" ] +} +``` + +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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/plans/execution-plan.md b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/execution-plan.md new file mode 100644 index 0000000..dfc7e49 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/execution-plan.md @@ -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
COMPLETED"] + RE["Reverse Engineering
COMPLETED"] + RA["Requirements Analysis
COMPLETED"] + US["User Stories
SKIP"] + WP["Workflow Planning
IN PROGRESS"] + AD["Application Design
EXECUTE"] + UG["Units Generation
EXECUTE"] + end + + subgraph CONSTRUCTION["🟢 CONSTRUCTION PHASE"] + FD["Functional Design
EXECUTE per unit"] + NFRA["NFR Requirements
SKIP"] + NFRD["NFR Design
EXECUTE per unit"] + ID["Infrastructure Design
EXECUTE per unit"] + CG["Code Generation
Planning plus Generation
EXECUTE"] + BT["Build and Test
EXECUTE"] + end + + subgraph OPERATIONS["🟡 OPERATIONS PHASE"] + DS["Deployment Setup
EXECUTE"] + MS["Monitoring Setup
EXECUTE"] + PRV["Production Readiness Validation
EXECUTE"] + 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 | diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/plans/unit-of-work-plan.md b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/unit-of-work-plan.md new file mode 100644 index 0000000..2c8daff --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/plans/unit-of-work-plan.md @@ -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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-clarification-questions.md b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-clarification-questions.md new file mode 100644 index 0000000..1871b27 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-clarification-questions.md @@ -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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-verification-questions.md b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-verification-questions.md new file mode 100644 index 0000000..6e85518 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirement-verification-questions.md @@ -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.`) +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()` 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 diff --git a/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirements.md b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirements.md new file mode 100644 index 0000000..d93d644 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/inception/requirements/requirements.md @@ -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_`) 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). -- 2.39.5 From 29a93ef8736c0e2c3321983c8689e101b5cf9788 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 28 Jul 2026 00:00:13 +0200 Subject: [PATCH 02/35] Separates website and admin roots, adds /health, hardens the availability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the single-host layout for deployment. The customer's public website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no longer overwrite content it does not own: with the website in its own directory, the release directory can be swapped without touching it. Each front-end gets its own file provider, and both tolerate a missing directory at startup — a fresh deployment has no website until a separate workspace deploys one, and the CMS must still serve /admin and the API. When the website's index.html is absent, an embedded placeholder is served instead of a 404, which also doubles as proof the CMS itself is running. The placeholder is embedded in the assembly rather than shipped into wwwroot/web/, because that directory is owned and overwritten by the website workspace. Adds GET /health for uptime monitoring. It reports infrastructure liveness only and is deliberately NOT the same thing as /api/v1/Availability/status or /api/v1/System/capabilities: those are CMS domain state that also serve the master/slave protocol. A healthy instance can be switched off by design, and a switched-on instance can be unhealthy, so conflating them would alert on business state and stay silent on real outages. /health is on the availability gate's bypass list for the same reason. Fixes a real defect in the gate's admin bypass. It parsed the bearer token with ReadJwtToken, which reads claims without verifying the signature, so an unauthenticated caller could forge an unsigned token carrying an Owner role claim and bypass the gate that suspends a customer's site. Protected endpoints still rejected them, so nothing leaked — but the gate itself was bypassable. The token is now fully validated against the same parameters as the bearer scheme, resolved from one shared source so the two cannot drift apart. Host wiring for these changes lands with the data-durability commit, since both units touch the same lines of Program.cs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw --- ...u1-hosting-serving-code-generation-plan.md | 149 ++++++++++++ .../plans/u1-hosting-serving-fd-questions.md | 69 ++++++ ...-hosting-serving-functional-design-plan.md | 47 ++++ .../code/generation-summary.md | 86 +++++++ .../functional-design/business-logic-model.md | 213 ++++++++++++++++++ .../functional-design/business-rules.md | 134 +++++++++++ .../functional-design/domain-entities.md | 128 +++++++++++ .../Extensions/StaticContentExtensions.cs | 187 +++++++++++++++ .../Extensions/WebsitePlaceholder.html | 56 +++++ .../SlpModularCms.Api.csproj | 10 + .../Hosting/AdminTokenValidatorTests.cs | 149 ++++++++++++ .../Hosting/HealthReportTests.cs | 56 +++++ .../Hosting/Health/HealthCheckExtensions.cs | 69 ++++++ .../Hosting/Health/HealthReport.cs | 23 ++ .../Hosting/JwtTokenValidation.cs | 41 ++++ .../Hosting/Security/AdminTokenValidator.cs | 64 ++++++ .../Hosting/Security/IAdminTokenValidator.cs | 24 ++ .../Hosting/ServiceCollectionExtensions.cs | 24 +- .../AvailabilityMiddlewareMasterGateTests.cs | 39 ++-- .../AvailabilityMiddlewareTests.cs | 151 ++++++++++--- .../Middleware/AvailabilityMiddleware.cs | 44 ++-- 21 files changed, 1677 insertions(+), 86 deletions(-) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md create mode 100644 src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs create mode 100644 src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html create mode 100644 src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs create mode 100644 src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs create mode 100644 src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs create mode 100644 src/SlpModularCms.Core/Hosting/Health/HealthReport.cs create mode 100644 src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs create mode 100644 src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md new file mode 100644 index 0000000..1dcc467 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-code-generation-plan.md @@ -0,0 +1,149 @@ +# Code Generation Plan — U1 Hosting & Serving + +**This plan is the single source of truth for Code Generation of U1.** Generation executes exactly these steps in order; no step is added or skipped during execution. + +--- + +## Unit Context + +| Aspect | Detail | +|---|---| +| **Unit** | U1 Hosting & Serving | +| **Round** | R1 (with U2 Data Durability) | +| **Workspace root** | `K:\Development\Projects\SlpModularCms` | +| **Project type** | Brownfield — existing structure retained, files modified in place | +| **Requirements** | FR-07, FR-10, FR-24 | +| **Components** | C-04 health checks, C-10 static mounts, C-13 availability middleware, U1 portion of C-16 | +| **Business rules** | BR-U1-01 … BR-U1-22 | +| **Depends on** | Nothing. U1 and U2 are mutually independent | +| **Depended on by** | U3 (path layout for CSP scoping), U6 (deploys into this layout) | +| **New database entities** | **None** — U1 adds no table, migration or configuration section | + +### Requirement traceability + +| Requirement | Implemented by steps | +|---|---| +| FR-07 — serve `/` from `wwwroot/web/`, `/admin` from `wwwroot/admin/` | 4, 5, 6 | +| FR-10 — `/health` liveness endpoint on the availability bypass list | 2, 6, 7 | +| FR-24 — validate the token in the availability gate's admin bypass | 3, 7, 8 | + +--- + +## Generation Steps + +### Step 1: Shared JWT validation parameters (Core) +- [x] Create `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` — a factory producing `TokenValidationParameters` from `JwtSettings`, with `ClockSkew.Zero`, matching the current inline configuration exactly +- [x] Modify `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` so `AddJwtBearer` consumes the factory instead of building parameters inline +- [x] Register the produced `TokenValidationParameters` as a singleton so the availability gate resolves the **same instance** + +*Implements the BR-U1-11 single-source constraint: two copies could drift, and a gate more permissive than the bearer scheme would silently re-open the hole FR-24 closes.* + +### Step 2: Health check registration (Core) +- [x] Create `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` with `AddCmsHealthChecks()` and `MapCmsHealthChecks()` +- [x] Create the `HealthReport` response model — `status`, `timestamp`, `version`, `modules` +- [x] Compose the report from in-process state only: no database call, no dependency probe (BR-U1-15) +- [x] Read module names from the existing `ModuleOrchestrator` +- [x] Read the version from the assembly's informational version +- [x] Deliberately expose **no** options parameter, so adding a database check later is a visible code change rather than configuration drift + +### Step 3: Admin token validator (Core) +- [x] Create `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` and `AdminTokenValidator.cs` +- [x] Validate the bearer token against the shared `TokenValidationParameters` from Step 1 — signature, issuer, audience and lifetime (BR-U1-11) +- [x] Return true only when validation succeeds **and** the principal carries role `Owner` or `Administrator` (BR-U1-13) +- [x] Return false — never throw — for absent, malformed, forged or expired tokens (BR-U1-12, BR-U1-14) +- [x] Register in `ServiceCollectionExtensions.AddCoreInfrastructure` + +### Step 4: Static content composition (Api host) +- [x] Create `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` with `UseCmsStaticContent()` +- [x] Register the `/admin` mount **first**, then the root mount, each with its own `PhysicalFileProvider` (BR-U1-01) +- [x] Enable default-file handling per mount; leave directory browsing disabled (BR-U1-07) +- [x] Tolerate a missing physical directory at startup for both mounts (BR-U1-20, BR-U1-22) +- [x] Log a warning naming the absolute expected path when a directory is absent (BR-U1-21) +- [x] Redirect the exact path `/admin` to `/admin/`, leaving deeper paths untouched (BR-U1-03) + +### Step 5: Placeholder page (Api host) +- [x] Create `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` — states no website is deployed, names the expected target path, links to `/admin` +- [x] Contain no version, environment name, module list or configuration (domain-entities.md) +- [x] Modify `src/SlpModularCms.Api/SlpModularCms.Api.csproj` to embed it as an `EmbeddedResource` +- [x] Serve it with status `200` when the website fallback is needed and `wwwroot/web/index.html` is absent (BR-U1-06) + +*Embedded rather than placed in `wwwroot/web/`, because that directory is owned and overwritten by a website workspace — a file there would be deleted by the first real deployment or mistaken for part of the customer's site.* + +### Step 6: Api host composition +- [x] Modify `src/SlpModularCms.Api/Program.cs`: + - [x] Replace `UseDefaultFiles()` + `UseStaticFiles()` with `UseCmsStaticContent()` + - [x] Register `AddCmsHealthChecks()` alongside the existing service registrations + - [x] Map `MapCmsHealthChecks()` after `MapControllers()` + - [x] Retarget both `MapFallbackToFile` registrations to the two mounts, preserving the `nonfile` constraint (BR-U1-04, BR-U1-05) + +### Step 7: Slave host composition +- [x] Modify `src/SlpModularCms.Api.Slave/Program.cs`: + - [x] Register `AddCmsHealthChecks()` and map `MapCmsHealthChecks()` + - [x] Add **no** static mounts — the Slave serves no static content (Q2 of Application Design = A) + +### Step 8: Availability middleware (Modules.Availability) +- [x] Modify `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs`: + - [x] Add `/health` to `_bypassPrefixes` (BR-U1-09), leaving the existing entries unchanged (BR-U1-10) + - [x] Replace the `JwtSecurityTokenHandler.ReadJwtToken` call in `IsAdminBypass` with the injected `IAdminTokenValidator` + - [x] Remove the now-unused `System.IdentityModel.Tokens.Jwt` usage + +### Step 9: Core unit tests +- [x] Create `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` — valid Owner token accepted; valid Administrator accepted; valid User rejected; forged unsigned token rejected; expired token rejected; malformed header rejected; absent header rejected +- [x] Create `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` — report composition; module names sourced from the orchestrator; no configuration or path values present + +### Step 10: Availability module unit tests +- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` for the constructor change, and add: `/health` bypasses while the instance is disabled; a forged Owner token grants **no** bypass; a valid Owner token still bypasses +- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` for the constructor change + +### Step 11: Static content unit tests — **DEVIATED, see generation-summary.md** +- [~] `StaticContentTests.cs` **not created**: the plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in `SlpModularCms.Api`, which `Core.Tests` does not reference. `SlpModularCms.Api` has no test project by the same convention that gives `SlpModularCms.Api.Slave` none +- [x] Behaviour requiring a composed pipeline recorded in the unit summary as carried to the phase-level Build and Test stage + +### Step 12: Documentation +- [x] Create `aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md` — files created and modified, decisions taken, and any deviation from this plan + +### Step 13: Build and test verification (automatic) +- [x] `dotnet build SlpModularCms.sln -c Release` +- [x] `dotnet test` for `SlpModularCms.Core.Tests` and `SlpModularCms.Modules.Availability.Tests` +- [x] Fix any failure directly and re-run until green +- [x] Record the outcome for the completion message + +--- + +## Files Touched + +### Created +| Path | Purpose | +|---|---| +| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Shared validation parameters | +| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | Health registration and endpoint | +| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract | +| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation | +| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition | +| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder | +| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | Tests | +| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | Tests | +| `src/SlpModularCms.Core.Tests/Hosting/StaticContentTests.cs` | Tests | + +### Modified +| Path | Change | +|---|---| +| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Use the shared factory; register the validator | +| `src/SlpModularCms.Api/Program.cs` | Static content, health checks, retargeted fallbacks | +| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Embed the placeholder | +| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks only | +| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; validated admin bypass | +| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change plus new cases | +| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change | + +**Brownfield rule**: every file above that exists is modified in place. No `*_new`, `*_modified` or parallel copies. + +--- + +## Out of Scope for U1 + +- Security headers — U3 +- Sentry, Umami, same-origin frontend config — U4 +- Data Protection and automatic migrations — U2 +- Anything under `.gitea/` — U5 and U6 +- README and website contract — U7 diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md new file mode 100644 index 0000000..facab4d --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-fd-questions.md @@ -0,0 +1,69 @@ +# Functional Design Questions — U1 Hosting & Serving + +Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past. + +--- + +## Question 1 — Hoe implementeren we de FR-24-fix? + +**Context**: dit is het conflict uit § 5.2 van het applicatieontwerp. `AvailabilityMiddleware` wordt geïnstalleerd door `orchestrator.UseModules(app)`, wat **vóór** `app.UseAuthentication()` staat. Op dat moment is `HttpContext.User` dus nog leeg — de middleware kan niet simpelweg de al geauthenticeerde gebruiker uitlezen. + +Dat is precies waarom de huidige code `ReadJwtToken` gebruikt: die werkt zonder authenticatie, maar valideert de handtekening niet. + +A) Valideer het token in de middleware zelf, met dezelfde `TokenValidationParameters` als het bearer-schema — die parameters worden dan uit één gedeelde bron gehaald in plaats van gekopieerd. Afgebakend: alleen deze middleware verandert +B) Verplaats `app.UseAuthentication()` naar vóór `orchestrator.UseModules(app)`, zodat de middleware `HttpContext.User` kan gebruiken. Kleinere wijziging in regels code, maar verandert de pipeline voor élke module — ook toekomstige +C) Laat de gate-bypass helemaal vervallen en gebruik in plaats daarvan een vaste bypass-prefix voor de admin-endpoints — geen tokenlogica meer in de middleware +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:A + +--- + +## Question 2 — Wat gebeurt er als `wwwroot/web/` niet bestaat? + +**Context**: bij een verse deploy is er nog geen publieke website — die komt uit een andere workspace. De CMS moet dan gewoon starten en `/admin` en `/api/v1` blijven serveren. Maar wat krijgt een bezoeker op `/` te zien? + +A) Een standaard 404 — er is niets, dus dat is het eerlijke antwoord +B) Een ingebouwde placeholderpagina met de melding dat er nog geen website is geplaatst, plus een verwijzing naar `/admin` — handig bij een verse installatie, en meteen bewijs dat de CMS draait +C) Een redirect naar `/admin` — de enige zinvolle bestemming zolang er geen website is +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:B + +--- + +## Question 3 — Moet de applicatie hierover iets loggen bij het opstarten? + +**Context**: een ontbrekende `wwwroot/web/` is bij een verse installatie normaal, maar op een draaiende productieomgeving zou het betekenen dat de website van de klant verdwenen is — precies het scenario dat we met de release-opzet proberen te voorkomen. + +A) Waarschuwing bij opstarten als de map ontbreekt, met het verwachte pad erbij — zichtbaar in Sentry en de console, zonder het opstarten te blokkeren +B) Alleen een informatieregel — het is een normale toestand bij een verse installatie +C) Niets loggen — de 404 of placeholder zegt genoeg +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:A + +--- + +## Question 4 — Wat geeft `/health` terug? + +**Context**: het framework-standaardantwoord is platte tekst `Healthy` met status 200, of `Unhealthy` met 503. UptimeRobot heeft aan de statuscode genoeg. + +A) De standaard platte tekst — minimaal, snel, en geeft niets prijs over de applicatie +B) Een klein JSON-object met status en tijdstip — iets makkelijker te lezen bij handmatig controleren +C) JSON met status, tijdstip, versie en geladen modules — dan zie je meteen of alle modules geladen zijn na een deploy +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:C + +--- + +## Question 5 — Moet `/admin` zonder slash doorverwijzen naar `/admin/`? + +**Context**: de admin-SPA is gebouwd met `base: '/admin/'`. Als iemand `/admin` intypt zonder afsluitende slash, worden relatieve verwijzingen in de pagina één niveau te hoog opgelost, waardoor de SPA stuk kan gaan. Een redirect naar `/admin/` voorkomt dat. + +A) Ja, redirect `/admin` naar `/admin/` — voorkomt een categorie fouten die lastig te herkennen is +B) Nee, laat de SPA-fallback het afhandelen — minder magie in de pipeline +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:A diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md new file mode 100644 index 0000000..afa5847 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u1-hosting-serving-functional-design-plan.md @@ -0,0 +1,47 @@ +# Functional Design Plan — U1 Hosting & Serving + +**Unit**: U1 Hosting & Serving +**Round**: R1 (with U2 Data Durability) +**Requirements**: FR-07, FR-10, FR-24 +**Components**: C-04, C-10, C-13, U1 portion of C-16 + +--- + +## Step 1: Analyze unit context +- [x] Read the U1 definition from `unit-of-work.md` +- [x] Read the requirement assignment from `unit-of-work-story-map.md` +- [x] Read the carried-in design items — § 5.2 pipeline ordering, missing-directory startup behaviour + +## Step 2: Design static-file serving behaviour +- [x] Define mount registration order and request-path resolution for the two mounts +- [x] Define default-file handling per mount +- [x] Define SPA fallback precedence between `/admin/{*path:nonfile}` and `{*path:nonfile}` +- [x] Define behaviour when `wwwroot/web/` is absent at startup +- [x] Define behaviour when `wwwroot/web/` exists but has no `index.html` +- [x] Define trailing-slash handling for `/admin` +- [x] Confirm directory browsing stays disabled + +## Step 3: Design the health endpoint +- [x] Define the response contract for healthy and unhealthy states +- [x] Confirm no dependency probing is performed +- [x] Define behaviour while the instance is availability-disabled +- [x] Define what the endpoint must never expose + +## Step 4: Design the availability-gate changes +- [x] Define the `/health` bypass placement within the existing prefix list +- [x] Resolve the FR-24 implementation approach — pipeline ordering versus in-middleware validation +- [x] Define admin-bypass behaviour for valid, forged, expired and absent tokens +- [x] Confirm the preserved behaviour: a valid Owner or Administrator token still bypasses the gate + +## Step 5: Define business rules +- [x] Enumerate path-resolution rules with precedence +- [x] Enumerate health-reporting rules +- [x] Enumerate admin-bypass rules +- [x] Identify error and edge-case scenarios + +## Step 6: Generate artifacts +- [x] Generate `business-logic-model.md` +- [x] Generate `business-rules.md` +- [x] Generate `domain-entities.md` +- [x] Validate all diagrams against the Mermaid standards +- [x] Verify Security Baseline compliance for this unit's design diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md new file mode 100644 index 0000000..61044d0 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md @@ -0,0 +1,86 @@ +# Code Generation Summary — U1 Hosting & Serving + +**Date**: 2026-07-27 +**Requirements**: FR-07, FR-10, FR-24 + +--- + +## Files Created + +| Path | Purpose | +|---|---| +| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Single source of the JWT validation parameters | +| `src/SlpModularCms.Core/Hosting/Health/HealthReport.cs` | Liveness response model | +| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | `AddCmsHealthChecks()` / `MapCmsHealthChecks()` | +| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract for the validated admin bypass | +| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation | +| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition and SPA fallbacks | +| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder page | +| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | 13 tests | +| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | 3 tests | + +## Files Modified + +| Path | Change | +|---|---| +| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Validation parameters built once via the factory, registered as a singleton and shared with the bearer scheme; `IAdminTokenValidator` registered | +| `src/SlpModularCms.Api/Program.cs` | `AddCmsHealthChecks()`, `UseCmsStaticContent()`, `MapCmsHealthChecks()`, `MapCmsSpaFallbacks()` | +| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Placeholder embedded as a resource | +| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks; no static mounts | +| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; `IsAdminBypass` delegates to the validator; unvalidated `ReadJwtToken` removed | +| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change; new bypass and forged-token cases | +| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change; `/health` bypass case | + +No duplicate or parallel files were created — every existing file was modified in place. + +--- + +## Implementation Decisions + +### The forged-token fix is proven against the real validator, not only a substitute +The middleware's own tests substitute `IAdminTokenValidator`, which is correct unit-testing practice — the middleware's job is to *ask*, not to validate. But a substitute keeps passing even if the middleware were later rewired back to unvalidated token parsing. + +A nested `WithRealValidator` class therefore wires the middleware to the actual `AdminTokenValidator` and asserts both halves of the fix: a **forged unsigned Owner token is rejected**, and a **genuine Owner token still bypasses**. The second matters as much as the first — an administrator must always be able to reach a disabled instance to switch it back on. + +### `AddCmsHealthChecks()` deliberately takes no options +Adding a database probe therefore requires editing this method, which is visible in review, rather than flipping a setting. Liveness-only is enforced by the shape of the API instead of by discipline. + +### The placeholder is an embedded resource, and that was verified +`wwwroot/web/` is owned and overwritten by a separate website workspace, so a placeholder file there would be deleted by the first real deployment or mistaken for part of the customer's site. Embedding keeps it outside that boundary. + +Because a wrong resource name would fail *silently* — falling back to a minimal inline HTML string — the compiled assembly's manifest was inspected to confirm the name resolves: `SlpModularCms.Api.Extensions.WebsitePlaceholder.html`. + +### Static mounts are resolved at startup +`RegisterMount` only registers a mount when its directory exists, so a directory created *after* the process started is not served until the next restart. This is correct for the intended deployment model — the atomic release switch links `wwwroot/web/` into place before the process starts — but it is behaviour worth knowing: dropping a website into a running instance requires a restart. + +--- + +## Deviation From the Plan + +**Step 11 (`StaticContentTests`) was not implemented as written.** The plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in the `SlpModularCms.Api` project, which `Core.Tests` does not reference and must not. + +`SlpModularCms.Api` has no test project, by the same deliberate convention that gives `SlpModularCms.Api.Slave` none — the Clients solution folder holds deployables, not tested libraries. Creating one would have been a structural change outside this unit's scope. + +What the step was meant to cover is mostly ASP.NET Core's own static-file behaviour rather than this project's logic. The genuinely project-specific behaviours — mount ordering, fallback precedence, the `nonfile` constraint, the placeholder path and the `/admin` redirect — require a composed host and are therefore **carried to the phase-level Build and Test stage**, where both hosts are started. + +Carried to Build and Test: +- `/admin` redirects to `/admin/` +- A missing asset under either mount returns `404`, never HTML +- A client-side route under `/admin` serves the admin `index.html` +- A client-side route at the root serves the website `index.html`, or the placeholder when absent +- `/health` answers while the instance is availability-disabled + +--- + +## Verification + +| Check | Result | +|---|---| +| `dotnet build SlpModularCms.sln -c Release` | ✅ 0 errors | +| `SlpModularCms.Core.Tests` | ✅ 83 passed (was 54) | +| `SlpModularCms.Modules.Availability.Tests` | ✅ 82 passed (was 78) | +| `SlpModularCms.Modules.Identity.Tests` | ✅ 37 passed (unchanged) | +| `SlpModularCms.Modules.Master.Tests` | ✅ 51 passed (was 50) | +| Embedded resource name resolves | ✅ Verified against the compiled assembly manifest | + +No failures occurred during generation; nothing needed fixing and retrying. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md new file mode 100644 index 0000000..b4e3f17 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md @@ -0,0 +1,213 @@ +# Business Logic Model — U1 Hosting & Serving + +**Unit**: U1 Hosting & Serving +**Requirements**: FR-07, FR-10, FR-24 + +--- + +## 1. Scope of the Logic + +U1 contains no domain business logic in the usual sense — no customer, order or invoice. What it does contain is **request-resolution logic**: given an incoming path, which of three co-hosted surfaces should answer, and under what conditions may the answer be suppressed. + +Three decisions are made per request: +1. Which static mount, if any, owns this path +2. Whether the availability gate applies +3. Which fallback resolves a client-side route + +--- + +## 2. Request Resolution Flow + +```mermaid +graph TD + req["Incoming request"] + hdr["Security headers register
response-start callback"] + adminmount{"Path starts with /admin ?"} + adminslash{"Path is exactly /admin
without trailing slash ?"} + redirect["308 redirect to /admin/"] + adminfile{"File exists in
wwwroot/admin ?"} + serveadmin["Serve admin asset"] + webfile{"File exists in
wwwroot/web ?"} + serveweb["Serve website asset"] + gate["Availability gate"] + bypass{"Bypass prefix
or valid admin token ?"} + blocked["503 ProblemDetails"] + route["Routing"] + health{"Path is /health ?"} + healthresp["Health report"] + api{"Path starts with /api/v1 ?"} + ctrl["Controller"] + fallback{"Path has a file extension ?"} + notfound["404"] + whichspa{"Path starts with /admin ?"} + adminindex["Serve wwwroot/admin/index.html"] + webindex{"wwwroot/web/index.html
exists ?"} + serveindex["Serve website index.html"] + placeholder["Serve built-in placeholder page"] + + req --> hdr + hdr --> adminmount + adminmount -->|yes| adminslash + adminslash -->|yes| redirect + adminslash -->|no| adminfile + adminfile -->|yes| serveadmin + adminfile -->|no| gate + adminmount -->|no| webfile + webfile -->|yes| serveweb + webfile -->|no| gate + gate --> bypass + bypass -->|no, and disabled| blocked + bypass -->|yes or available| route + route --> health + health -->|yes| healthresp + health -->|no| api + api -->|yes| ctrl + api -->|no| fallback + fallback -->|yes| notfound + fallback -->|no| whichspa + whichspa -->|yes| adminindex + whichspa -->|no| webindex + webindex -->|yes| serveindex + webindex -->|no| placeholder + + classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef serve fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; + class req,hdr entry; + class adminmount,adminslash,adminfile,webfile,bypass,health,api,fallback,whichspa,webindex decision; + class serveadmin,serveweb,route,healthresp,ctrl,adminindex,serveindex,placeholder,redirect serve; + class blocked,notfound bad; +``` + +Text alternative: static mounts are checked first and short-circuit when a file exists; everything else passes the availability gate, then routes to the health endpoint, a controller, a 404 for missing assets, or one of the two SPA fallbacks — with a built-in placeholder when the website's index is absent. + +**Key property**: static files short-circuit before the availability gate. A deliberately disabled instance therefore still serves the customer's website while blocking `/api/v1` and the admin SPA's routes. This is pre-existing behaviour, retained deliberately (see `architecture.md`). + +--- + +## 3. Admin Bypass Evaluation (FR-24) + +The gate's admin bypass currently parses the bearer token **without verifying its signature**, so a forged token grants bypass. Per Q1 = A the middleware will validate the token itself using the same `TokenValidationParameters` as the JWT bearer scheme, obtained from a **single shared source** rather than copied. + +This approach was chosen over moving `UseAuthentication()` earlier, which would have changed the pipeline for every module including future ones. + +```mermaid +sequenceDiagram + box rgba(246,224,94,0.4) Caller + participant C as Client + end + box rgba(144,205,244,0.4) Gate + participant M as AvailabilityMiddleware + participant V as Token validator + end + box rgba(154,230,180,0.4) Downstream + participant N as Rest of pipeline + end + C->>M: Request with Authorization header + M->>M: check bypass prefixes + M->>V: validate token with shared parameters + alt token valid and role is Owner or Administrator + V-->>M: validated principal + M->>N: continue, gate bypassed + else token invalid, forged, or expired + V-->>M: validation failed + M->>M: evaluate master gate and local status + M-->>C: 503 if disabled, otherwise continue + end +``` + +Text alternative: the middleware validates the bearer token with the same parameters as the bearer scheme; only a genuinely valid Owner or Administrator token bypasses the gate, while a forged or expired token falls through to normal availability evaluation. + +**Behaviour preserved**: an administrator with a valid token can always reach a disabled instance to switch it back on. **Behaviour removed**: an unauthenticated caller can no longer bypass the gate with a self-made token. + +--- + +## 4. Health Reporting + +`/health` reports **infrastructure liveness only**. It performs no database call and probes no dependency (D-21). + +Per Q4 = C the response is JSON containing status, timestamp, application version and loaded module names. + +```mermaid +graph TD + call["GET /health"] + bypasslist["On the availability bypass list
so a disabled instance still answers"] + inproc["Read in-process state only
no database, no dependency probe"] + compose["Compose report:
status, timestamp, version, modules"] + ok["200 Healthy"] + dead["Process not running:
no response at all"] + + call --> bypasslist + bypasslist --> inproc + inproc --> compose + compose --> ok + call -.->|"if startup failed"| dead + + classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef step 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 call entry; + class bypasslist,inproc,compose step; + class ok good; + class dead bad; +``` + +Text alternative: the health endpoint answers from in-process state only and stays reachable while the instance is disabled; the failure signal is the absence of a response when the process did not start. + +**Why the module list is included**: `ModuleOrchestrator` logs rather than throws when a module fails to load, so an instance can start successfully with reduced capability. Without this field there is no way to detect that after a deploy without host access — which NFR-06 explicitly requires. + +**Where the "unhealthy" signal comes from**: not from this endpoint reporting failure, but from the process not answering at all. U2's fail-fast startup is what produces that signal. A liveness check whose process always starts would be worthless; combined with fail-fast migration it is meaningful. + +--- + +## 5. Missing Website Directory + +A fresh deployment has no `wwwroot/web/` — the customer's website is deployed separately. The CMS must still start and serve `/admin` and `/api/v1` (Q2 = B, Q3 = A). + +```mermaid +graph TD + boot["Startup"] + check{"wwwroot/web exists ?"} + warn["Log a warning with the expected path"] + normal["Register mount normally"] + reg["Register mount tolerating absence"] + run["Application starts either way"] + visit["Visitor requests /"] + hasindex{"index.html present ?"} + site["Serve the website"] + ph["Serve built-in placeholder
explaining no site is deployed
and linking to /admin"] + + boot --> check + check -->|no| warn + warn --> reg + check -->|yes| normal + reg --> run + normal --> run + run --> visit + visit --> hasindex + hasindex -->|yes| site + hasindex -->|no| ph + + classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef warnnode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + class boot,visit entry; + class check,hasindex decision; + class normal,reg,run,site,ph step; + class warn warnnode; +``` + +Text alternative: a missing website directory logs a warning but never blocks startup; visitors then receive a built-in placeholder page instead of an error, and the admin SPA and API remain fully available. + +**Why a warning rather than an informational line** (Q3 = A): on a fresh install the absence is normal, but on a running production instance it means the customer's website has vanished — the exact scenario the release design exists to prevent. A warning is visible in Sentry without blocking startup, so the normal case costs nothing while the dangerous case is not silent. + +--- + +## 6. Trailing Slash for `/admin` + +The admin SPA is built with `base: '/admin/'`. A request to `/admin` without the trailing slash resolves relative references one level too high, breaking asset loading in a way that looks like a deployment fault. Per Q5 = A, `/admin` redirects to `/admin/`. + +The redirect applies **only** to the exact path `/admin`. Deeper paths such as `/admin/dashboard` are handled by the SPA fallback unchanged. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md new file mode 100644 index 0000000..c4564dc --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-rules.md @@ -0,0 +1,134 @@ +# Business Rules — U1 Hosting & Serving + +--- + +## Rule Categories + +```mermaid +graph TD + start["Request or startup event"] + cat1{"Path resolution ?"} + cat2{"Availability gate ?"} + cat3{"Health reporting ?"} + cat4{"Startup validation ?"} + r1["BR-U1-01 to BR-U1-08"] + r2["BR-U1-09 to BR-U1-14"] + r3["BR-U1-15 to BR-U1-19"] + r4["BR-U1-20 to BR-U1-22"] + + start --> cat1 + start --> cat2 + start --> cat3 + start --> cat4 + cat1 --> r1 + cat2 --> r2 + cat3 --> r3 + cat4 --> r4 + + classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef rules fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + class start entry; + class cat1,cat2,cat3,cat4 decision; + class r1,r2,r3,r4 rules; +``` + +Text alternative: rules divide into four groups — path resolution, availability gating, health reporting and startup validation. + +--- + +## Path Resolution Rules + +| ID | Rule | +|---|---| +| **BR-U1-01** | The `/admin` mount is evaluated **before** the root mount. A path beginning `/admin` is never resolved against `wwwroot/web/`. | +| **BR-U1-02** | `wwwroot/admin/` serves paths under `/admin`; `wwwroot/web/` serves paths under `/`. Neither mount may serve files from outside its own directory. | +| **BR-U1-03** | A request for the exact path `/admin` (no trailing slash) returns a redirect to `/admin/`. Deeper paths are unaffected. | +| **BR-U1-04** | A request whose path contains a file extension and matches no file returns `404`. It is never given an `index.html`. | +| **BR-U1-05** | A request whose path contains no file extension and matches no file falls back to an `index.html`: `wwwroot/admin/index.html` when the path begins `/admin`, otherwise the website's. | +| **BR-U1-06** | When the website fallback is required but `wwwroot/web/index.html` does not exist, the built-in placeholder page is returned with status `200`. | +| **BR-U1-07** | Directory browsing is disabled on both mounts. A request for a directory path returns its default file or falls through to the fallback rules — never a file listing. | +| **BR-U1-08** | Static-file responses short-circuit the pipeline. Any behaviour that must apply to them — notably security headers — must be registered before the static-file middleware. | + +**Rationale for BR-U1-04**: distinguishing "missing asset" from "client-side route" is what keeps a broken deployment visible. Without it, a missing JavaScript bundle would receive an HTML page, and the browser error would point at a parse failure rather than the real cause. + +--- + +## Availability Gate Rules + +| ID | Rule | +|---|---| +| **BR-U1-09** | `/health` is on the bypass prefix list. The availability gate never blocks it. | +| **BR-U1-10** | The existing bypass prefixes are retained unchanged: `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus`. | +| **BR-U1-11** | The admin bypass applies only when the bearer token **validates successfully** against the same `TokenValidationParameters` used by the JWT bearer scheme — signature, issuer, audience and lifetime. | +| **BR-U1-12** | A token that fails validation for any reason grants no bypass. The request proceeds to normal availability evaluation as if no token were present. | +| **BR-U1-13** | A validated token grants bypass only when it carries the role `Owner` or `Administrator`. | +| **BR-U1-14** | Token validation failure is never itself an error response. The gate does not return `401`; that remains the responsibility of the authentication middleware on protected endpoints. | + +**Rationale for BR-U1-11 and BR-U1-12**: this is the FR-24 fix. Previously the token was parsed but not verified, so an unauthenticated caller could present a self-made token carrying an `Owner` claim and bypass the gate. Protected endpoints still rejected them, so no data was exposed — but the gate itself, the mechanism that suspends a customer's site, was bypassable by anyone who knew the claim name. + +**Rationale for BR-U1-14**: the gate's job is to decide whether to serve, not to authenticate. Returning `401` from the gate would change the response for anonymous endpoints that are legitimately reachable, such as `/api/v1/Setup/status`. + +**Single source for validation parameters**: the parameters must be resolved from one shared definition used by both the bearer scheme and the gate. Copying them would allow the two to drift, and a drift in which the gate is *more* permissive than the scheme silently re-opens the hole this rule closes. + +--- + +## Health Reporting Rules + +| ID | Rule | +|---|---| +| **BR-U1-15** | `/health` performs no database call and probes no external dependency. Its answer is derived entirely from in-process state. | +| **BR-U1-16** | A running process always answers `200`. The unhealthy signal is the **absence** of a response, produced by fail-fast startup (U2). | +| **BR-U1-17** | The response body reports status, timestamp, application version and the names of loaded modules. | +| **BR-U1-18** | `/health` is anonymous. It must never expose configuration values, connection strings, environment variable contents, file paths, or stack traces. | +| **BR-U1-19** | `/health` is never presented as, aliased to, or documented as equivalent to `/api/v1/Availability/status` or `/api/v1/System/capabilities`. Those report CMS domain state; `/health` reports infrastructure liveness. | + +**Rationale for BR-U1-17**: the module list exists because `ModuleOrchestrator` logs rather than throws when a module fails to load. An instance can therefore start "successfully" with a missing capability, and NFR-06 requires that to be detectable after a deploy without host access. The version field serves the same purpose for the deploy itself — confirming which build is actually running. + +**Disclosure note**: module names are already publicly available from `/api/v1/System/capabilities`, which is anonymous, so BR-U1-17 adds no new disclosure there. The version field *is* new disclosure. It is accepted deliberately: verifying which build is live is the primary reason the endpoint exists, and the alternative — an authenticated health endpoint — would not work with UptimeRobot. Recorded as a conscious trade-off rather than an oversight. + +--- + +## Startup Validation Rules + +| ID | Rule | +|---|---| +| **BR-U1-20** | A missing `wwwroot/web/` directory never prevents startup. | +| **BR-U1-21** | A missing `wwwroot/web/` directory is logged as a **warning** at startup, including the absolute path that was expected. | +| **BR-U1-22** | A missing `wwwroot/admin/` directory is logged as a warning but likewise does not prevent startup — it indicates a publish problem, not a reason to refuse traffic to `/api/v1`. | + +**Rationale for BR-U1-21**: normal on a fresh installation, alarming on a running production instance where it means the customer's website has disappeared. A warning is visible in Sentry and the console without cost in the normal case. + +--- + +## Error and Edge-Case Scenarios + +| Scenario | Expected behaviour | +|---|---| +| Fresh install, no website deployed, visitor requests `/` | Placeholder page, `200` | +| Fresh install, visitor requests `/admin` | Redirect to `/admin/`, then the admin SPA | +| Website deployed but `index.html` missing | Placeholder page, `200`. The directory existing is not proof of a valid site | +| Request for `/assets/app.js` that does not exist | `404`, never HTML | +| Request for `/admin/assets/app.js` that does not exist | `404`, never HTML | +| Request for `/some/client/route` with no extension | Website `index.html`, or placeholder if absent | +| Request for `/admin/dashboard` | `wwwroot/admin/index.html` | +| Instance disabled, request for `/health` | `200` with the health report — the gate does not apply | +| Instance disabled, request for `/` where the website exists | The website is served — static files short-circuit before the gate | +| Instance disabled, request for `/admin/dashboard` | `503 ProblemDetails` — the fallback is an endpoint, so the gate applies | +| Instance disabled, valid Owner token | Bypass granted, request proceeds | +| Instance disabled, forged unsigned token claiming Owner | **No bypass.** `503`. This is the FR-24 fix | +| Instance disabled, expired but genuine Owner token | No bypass — lifetime validation is part of BR-U1-11 | +| Instance disabled, no token | `503 ProblemDetails` | +| Malformed `Authorization` header | Treated as no token; no exception surfaces to the caller | +| Path traversal attempt, e.g. `/../appsettings.json` | Rejected by the file provider; never resolves outside its mount root | + +--- + +## Security Compliance for U1 + +| Rule | Status | Notes | +|---|---|---| +| SECURITY-05 | Compliant | `/health` accepts no input. Path traversal is prevented by the file providers | +| SECURITY-08 | **Improved** | BR-U1-11 to BR-U1-14 close the forged-token bypass. `/health` is deliberately anonymous and exposes no resource data | +| SECURITY-09 | Compliant | Directory browsing disabled; BR-U1-18 forbids exposing internals; the version disclosure in BR-U1-17 is documented and justified | +| SECURITY-15 | Compliant | The gate fails closed — a token that cannot be validated grants nothing | diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md new file mode 100644 index 0000000..c32948c --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/domain-entities.md @@ -0,0 +1,128 @@ +# Domain Entities — U1 Hosting & Serving + +**Note**: U1 introduces **no persisted entity**. It adds no table, no migration and no database column. Its "domain" consists of in-memory configuration descriptors and a response model. They are documented here because they are the data structures the unit's logic operates on, and because reviewers should be able to confirm that nothing is being persisted. + +--- + +## Concept Relationships + +```mermaid +graph TD + host["Host application"] + mountweb["StaticMount: website
request path /"] + mountadmin["StaticMount: admin SPA
request path /admin"] + provider["File provider
per mount"] + fallbackweb["SPA fallback: website"] + fallbackadmin["SPA fallback: admin"] + placeholder["Placeholder page
embedded resource"] + report["HealthReport
response model"] + orchestrator["ModuleOrchestrator
existing"] + bypass["Bypass prefix list
existing, extended"] + tokenparams["Token validation parameters
existing, now shared"] + + host -->|"registers"| mountweb + host -->|"registers"| mountadmin + mountweb -->|"resolves files via"| provider + mountadmin -->|"resolves files via"| provider + mountweb -->|"falls back to"| fallbackweb + mountadmin -->|"falls back to"| fallbackadmin + fallbackweb -->|"substitutes when index absent"| placeholder + host -->|"exposes"| report + report -->|"reads module names from"| orchestrator + host -->|"configures"| bypass + host -->|"shares"| tokenparams + bypass -->|"used by availability gate"| tokenparams + + classDef hostnode fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef mount fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef model fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef existing fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000; + class host hostnode; + class mountweb,mountadmin,provider,fallbackweb,fallbackadmin,placeholder mount; + class report model; + class orchestrator,bypass,tokenparams existing; +``` + +Text alternative: the host registers two independent static mounts each with its own file provider and SPA fallback, plus a health response model that reads module names from the existing orchestrator; the bypass list and token validation parameters are existing structures this unit extends and shares. + +--- + +## Static Mount Descriptor (configuration, in-memory) + +Not a class to be persisted — this describes what each `UseStaticFiles` registration is configured with. + +| Field | Website mount | Admin mount | +|---|---|---| +| Physical root | `{contentRoot}/wwwroot/web` | `{contentRoot}/wwwroot/admin` | +| Request path | `""` (root) | `/admin` | +| Default file | `index.html` | `index.html` | +| Directory browsing | Disabled | Disabled | +| Tolerates missing root | **Yes** — logs a warning | Yes — logs a warning | +| Registration order | Second | **First** | + +**Registration order matters**: the admin mount must be registered first, or `/admin/...` would be resolved against the website root. + +--- + +## HealthReport (response model, not persisted) + +| Field | Type | Purpose | +|---|---|---| +| `status` | string | `"Healthy"`. A running process always reports healthy; absence of a response is the unhealthy signal | +| `timestamp` | timestamp with offset | When the report was produced, so a cached response is recognisable | +| `version` | string | The application's informational version, so a deploy can be confirmed without host access | +| `modules` | string array | Names of modules loaded by `ModuleOrchestrator` | + +**Validation and constraints**: +- Every field is derived from in-process state. No field may require a database query, file read or network call (BR-U1-15). +- No field may contain configuration values, paths, connection details or environment variable contents (BR-U1-18). +- `modules` is read from the existing `ModuleOrchestrator.ModuleNames`, which is already exposed anonymously by `/api/v1/System/capabilities` — so this field introduces no new disclosure. + +--- + +## Placeholder Page (embedded static content) + +| Property | Value | +|---|---| +| Storage | Embedded resource in the assembly, not a file in `wwwroot` | +| Served when | The website fallback is needed and `wwwroot/web/index.html` is absent | +| Status code | `200` | +| Content | A statement that no website has been deployed yet, the expected target path, and a link to `/admin` | + +**Why embedded rather than a file**: a file in `wwwroot/web/` would be inside the directory a website workspace owns and overwrites — it would be deleted by the first real website deployment, or worse, mistaken for part of the customer's site. Embedding keeps it outside that boundary entirely. + +**What it must not contain**: no version, no environment name, no module list, no configuration. It is served anonymously to any visitor of the site root, which is a wider audience than `/health`. + +--- + +## Extended Existing Structures + +### Bypass prefix list (`AvailabilityMiddleware`) + +| Aspect | Detail | +|---|---| +| Current contents | `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus` | +| Added by U1 | `/health` | +| Matching | Case-insensitive prefix match, unchanged | + +### Shared token validation parameters + +| Aspect | Detail | +|---|---| +| Currently | Configured once inside `AddJwtBearer` in `ServiceCollectionExtensions.AddCoreInfrastructure` | +| Change | Extracted so the same instance is used by both the bearer scheme and the availability gate | +| Constraint | Exactly one definition. Two copies could drift, and a gate more permissive than the scheme would silently re-open the hole FR-24 closes | + +--- + +## Persistence Summary + +| Question | Answer | +|---|---| +| New tables? | None | +| New migrations? | None | +| New columns? | None | +| New configuration sections? | None — U1 adds no `appsettings` section | +| Anything written to disk at runtime? | No | + +All persistence work in Round 1 belongs to **U2**, which adds the Data Protection keys table and the automatic Core migration. diff --git a/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs b/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs new file mode 100644 index 0000000..decce16 --- /dev/null +++ b/src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs @@ -0,0 +1,187 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; + +namespace SlpModularCms.Api.Extensions; + +/// +/// Serves the two independent front-ends this host carries. +/// +/// +/// Shared hosting typically allows only one site or application pool, so this single process +/// serves everything: +/// +/// / — the customer's public website, from wwwroot/web/. Built and +/// deployed separately; it is NOT part of this repository and must survive every CMS deploy. +/// /admin — the CMS admin SPA, from wwwroot/admin/, produced by +/// dotnet publish. +/// +/// Each mount gets its own file provider so neither can ever serve files belonging to the other, +/// and so each can later carry its own headers or caching without disturbing the other. +/// +public static class StaticContentExtensions +{ + /// Directory under the web root holding the customer's public website. + public const string WebsiteDirectoryName = "web"; + + /// Directory under the web root holding the admin SPA. + public const string AdminDirectoryName = "admin"; + + /// Request path the admin SPA is mounted at. + public const string AdminRequestPath = "/admin"; + + private const string PlaceholderResourceName = "SlpModularCms.Api.Extensions.WebsitePlaceholder.html"; + + /// + /// Registers both static mounts and the /admin trailing-slash redirect. + /// Must be called BEFORE any middleware that needs to observe static responses, because + /// static files short-circuit the pipeline. + /// + public static WebApplication UseCmsStaticContent(this WebApplication app) + { + var webRoot = app.Environment.WebRootPath + ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); + + var adminRoot = Path.Combine(webRoot, AdminDirectoryName); + var websiteRoot = Path.Combine(webRoot, WebsiteDirectoryName); + + var logger = app.Services.GetRequiredService() + .CreateLogger(typeof(StaticContentExtensions).FullName!); + + WarnIfMissing(logger, adminRoot, "admin SPA"); + WarnIfMissing(logger, websiteRoot, "public website"); + + // A request for exactly "/admin" must become "/admin/", or the SPA — built with + // base '/admin/' — resolves its relative asset references one level too high and + // fails in a way that looks like a broken deployment. + app.Use(async (context, next) => + { + if (context.Request.Path.Equals(AdminRequestPath, StringComparison.OrdinalIgnoreCase)) + { + var target = $"{AdminRequestPath}/{context.Request.QueryString}"; + context.Response.Redirect(target, permanent: true); + return; + } + + await next(); + }); + + // The admin mount is registered FIRST. Registered the other way around, a request for + // /admin/... would be resolved against the website root. + RegisterMount(app, adminRoot, AdminRequestPath); + RegisterMount(app, websiteRoot, requestPath: string.Empty); + + return app; + } + + /// + /// Maps the SPA fallbacks for both mounts. + /// + /// + /// The nonfile constraint on both routes is deliberate: a request for a path that + /// looks like a file (has an extension) and does not exist must stay a 404. Serving HTML + /// for a missing script would turn a clear "asset is missing" into a confusing parse error. + /// + public static WebApplication MapCmsSpaFallbacks(this WebApplication app) + { + var webRoot = app.Environment.WebRootPath + ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); + + var adminIndex = Path.Combine(webRoot, AdminDirectoryName, "index.html"); + var websiteIndex = Path.Combine(webRoot, WebsiteDirectoryName, "index.html"); + + app.MapFallback($"{AdminRequestPath}/{{*path:nonfile}}", async context => + { + if (File.Exists(adminIndex)) + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(adminIndex); + return; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + }); + + app.MapFallback("{*path:nonfile}", async context => + { + if (File.Exists(websiteIndex)) + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.SendFileAsync(websiteIndex); + return; + } + + // No website deployed yet. Serving the placeholder rather than a 404 makes a fresh + // installation self-explanatory and doubles as proof the CMS itself is running. + await WritePlaceholderAsync(context); + }); + + return app; + } + + private static void RegisterMount(WebApplication app, string physicalRoot, string requestPath) + { + // Tolerating a missing directory is required, not defensive: a fresh deployment has no + // wwwroot/web/ until a website workspace deploys into it, and the CMS must still start + // and serve /admin and /api/v1. + if (!Directory.Exists(physicalRoot)) + { + return; + } + + var provider = new PhysicalFileProvider(physicalRoot); + + app.UseDefaultFiles(new DefaultFilesOptions + { + FileProvider = provider, + RequestPath = requestPath + }); + + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = provider, + RequestPath = requestPath + // Directory browsing is not enabled — a request for a directory resolves to its + // default file or falls through to the fallback rules, never to a file listing. + }); + } + + private static async Task WritePlaceholderAsync(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/html; charset=utf-8"; + + // Embedded in the assembly rather than placed in wwwroot/web/, because that directory is + // owned and overwritten by a website workspace: a file there would be deleted by the first + // real website deployment, or mistaken for part of the customer's site. + await using var stream = typeof(StaticContentExtensions).Assembly + .GetManifestResourceStream(PlaceholderResourceName); + + if (stream is null) + { + await context.Response.WriteAsync("Nog geen website geplaatst" + + "

Er staat hier nog geen website. Beheer via /admin/.

"); + return; + } + + await stream.CopyToAsync(context.Response.Body); + } + + private static void WarnIfMissing(ILogger logger, string path, string description) + { + if (Directory.Exists(path)) + { + return; + } + + // Normal on a fresh installation, but on a running production instance it means the + // content has disappeared — worth being visible in Sentry without blocking startup. + logger.LogWarning( + "Static content directory for the {Description} was not found at {Path}. " + + "The application will start, but this path will not serve any files until content is deployed there.", + description, + path); + } +} diff --git a/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html new file mode 100644 index 0000000..2b8abf8 --- /dev/null +++ b/src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html @@ -0,0 +1,56 @@ + + + + + + + Nog geen website geplaatst + + + +
+

Er staat hier nog geen website

+

+ Het CMS draait, maar er is nog geen publieke website geplaatst. De website + wordt apart aangeleverd en hoort in de map wwwroot/web/ te staan, + met een index.html in de hoofdmap daarvan. +

+

+ Beheerders kunnen inloggen via /admin/. +

+

+ Deze pagina wordt automatisch vervangen zodra de website is geplaatst. +

+
+ + diff --git a/src/SlpModularCms.Api/SlpModularCms.Api.csproj b/src/SlpModularCms.Api/SlpModularCms.Api.csproj index 3f4e12c..85c0df0 100644 --- a/src/SlpModularCms.Api/SlpModularCms.Api.csproj +++ b/src/SlpModularCms.Api/SlpModularCms.Api.csproj @@ -17,6 +17,16 @@ + + + + + diff --git a/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs new file mode 100644 index 0000000..55d05dd --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs @@ -0,0 +1,149 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using FluentAssertions; +using Microsoft.IdentityModel.Tokens; +using SlpModularCms.Core.Hosting; +using SlpModularCms.Core.Hosting.Security; +using SlpModularCms.Core.Identity.Models; +using Xunit; + +namespace SlpModularCms.Core.Tests.Hosting; + +/// +/// Guards the availability gate's admin bypass. +/// +/// +/// The behaviour under test is the fix for a real defect: the bypass previously parsed the +/// bearer token without verifying its signature, so an unauthenticated caller could forge an +/// unsigned token carrying an Owner role claim and pass the gate. The forged-token cases below +/// are the point of this suite — the happy paths only prove the fix did not break the feature. +/// +public class AdminTokenValidatorTests +{ + private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!"; + private const string Issuer = "SlpModularCms"; + private const string Audience = "SlpModularCmsPortal"; + + private readonly AdminTokenValidator _validator; + + public AdminTokenValidatorTests() + { + var settings = new JwtSettings + { + Secret = Secret, + Issuer = Issuer, + Audience = Audience + }; + + _validator = new AdminTokenValidator(JwtTokenValidation.Create(settings)); + } + + [Theory] + [InlineData("Owner")] + [InlineData("Administrator")] + public void IsVerifiedAdmin_ShouldReturnTrue_ForValidAdminToken(string role) + { + var header = $"Bearer {CreateToken(role)}"; + + _validator.IsVerifiedAdmin(header).Should().BeTrue(); + } + + [Fact] + public void IsVerifiedAdmin_ShouldReturnFalse_ForValidNonAdminToken() + { + var header = $"Bearer {CreateToken("User")}"; + + _validator.IsVerifiedAdmin(header).Should().BeFalse(); + } + + [Fact] + public void IsVerifiedAdmin_ShouldReturnFalse_ForForgedUnsignedToken() + { + // The exact attack the fix closes: a token that carries the right claim but was never + // signed by us. Reading claims without validating would have accepted this. + var forged = CreateUnsignedToken("Owner"); + + _validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse(); + } + + [Fact] + public void IsVerifiedAdmin_ShouldReturnFalse_ForTokenSignedWithAnotherKey() + { + var otherKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes("AnEntirelyDifferentSecretKeyUsedByNobodyElse!!!!!")); + var credentials = new SigningCredentials(otherKey, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: [new Claim(ClaimTypes.Role, "Owner")], + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: credentials); + + var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}"; + + _validator.IsVerifiedAdmin(header).Should().BeFalse(); + } + + [Fact] + public void IsVerifiedAdmin_ShouldReturnFalse_ForExpiredAdminToken() + { + var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}"; + + _validator.IsVerifiedAdmin(header).Should().BeFalse(); + } + + [Fact] + public void IsVerifiedAdmin_ShouldReturnFalse_ForWrongIssuer() + { + var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}"; + + _validator.IsVerifiedAdmin(header).Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Bearer")] + [InlineData("Bearer ")] + [InlineData("Basic dXNlcjpwYXNz")] + [InlineData("Bearer not-a-token")] + [InlineData("Bearer a.b.c")] + public void IsVerifiedAdmin_ShouldReturnFalse_ForAbsentOrMalformedHeaders(string? header) + { + // Never throws — an unusable header simply means "not an admin". Rejecting the request + // is the authentication middleware's job, not the availability gate's. + _validator.IsVerifiedAdmin(header).Should().BeFalse(); + } + + private static string CreateToken( + string role, + TimeSpan? expiresIn = null, + string issuer = Issuer) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: Audience, + claims: [new Claim(ClaimTypes.Role, role)], + expires: DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromMinutes(10)), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static string CreateUnsignedToken(string role) + { + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: [new Claim(ClaimTypes.Role, role)], + expires: DateTime.UtcNow.AddMinutes(10)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs b/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs new file mode 100644 index 0000000..0dfd8fc --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using FluentAssertions; +using SlpModularCms.Core.Hosting.Health; +using Xunit; + +namespace SlpModularCms.Core.Tests.Hosting; + +/// +/// Guards the shape of the liveness report. +/// +/// +/// The report is served anonymously, so what it does NOT contain matters as much as what it does. +/// It carries the loaded module names because ModuleOrchestrator logs rather than throws +/// when a module fails to load — an instance can start "successfully" with a missing capability, +/// and this is the only way to detect that after a deploy without host access. +/// +public class HealthReportTests +{ + [Fact] + public void HealthReport_ShouldCarryTheFourReportedFields() + { + var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.2.3", ["Identity", "Availability"]); + + report.Status.Should().Be("Healthy"); + report.Version.Should().Be("1.2.3"); + report.Modules.Should().Equal("Identity", "Availability"); + report.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void HealthReport_ShouldSerializeWithoutAnyAdditionalFields() + { + // Anonymous endpoint: no configuration values, connection details, paths or environment + // data may leak in through an accidentally added property. + var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", ["Identity"]); + + var json = JsonSerializer.Serialize(report); + using var document = JsonDocument.Parse(json); + + document.RootElement.EnumerateObject() + .Select(p => p.Name.ToLowerInvariant()) + .Should().BeEquivalentTo("status", "timestamp", "version", "modules"); + } + + [Fact] + public void HealthReport_ShouldSupportAnEmptyModuleList() + { + // A host with no modules discovered is still alive. Liveness must not depend on + // capability — that distinction is the entire reason this endpoint exists separately + // from /api/v1/System/capabilities. + var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", []); + + report.Modules.Should().BeEmpty(); + report.Status.Should().Be("Healthy"); + } +} diff --git a/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs b/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs new file mode 100644 index 0000000..cae0b9c --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace SlpModularCms.Core.Hosting.Health; + +/// +/// Registers the infrastructure liveness endpoint. +/// +/// +/// Deliberately takes no options parameter. Adding a database probe or any other dependency +/// check must therefore be a visible code change here rather than a configuration setting +/// someone can flip — liveness-only is enforced by the shape of this API, not by discipline. +/// +/// Why liveness alone is a meaningful signal: startup applies database migrations and fails +/// fast when they cannot be applied (see ). A process +/// that cannot reach its database therefore never starts, so /health stops answering +/// entirely. The unhealthy signal is the absence of a response, not a response saying so. +/// +public static class HealthCheckExtensions +{ + /// Path of the liveness endpoint. Also present in the availability gate's bypass list. + public const string HealthPath = "/health"; + + public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services) + { + services.AddHealthChecks(); + return services; + } + + /// + /// Maps GET /health, returning a JSON report composed from in-process state only. + /// + public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet(HealthPath, (HttpContext context) => + { + var orchestrator = context.RequestServices.GetService(); + + var report = new HealthReport( + Status: "Healthy", + Timestamp: DateTimeOffset.UtcNow, + Version: GetVersion(), + // Module names are already public via /api/v1/System/capabilities, so including + // them here discloses nothing new. They are included because ModuleOrchestrator + // logs rather than throws when a module fails to load: an instance can start + // "successfully" with a missing capability, and this is the only way to detect + // that after a deploy without host access. + Modules: orchestrator?.ModuleNames ?? []); + + return Results.Ok(report); + }) + .AllowAnonymous() + .WithName("HealthCheck"); + + return endpoints; + } + + private static string GetVersion() + { + var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + + return assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + } +} diff --git a/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs b/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs new file mode 100644 index 0000000..20bad01 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Health/HealthReport.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SlpModularCms.Core.Hosting.Health; + +/// +/// Response model for the infrastructure liveness endpoint. +/// +/// +/// Reports infrastructure liveness ONLY. This is deliberately not the same thing as the CMS's +/// own availability state (/api/v1/Availability/status) or its loaded-capability report +/// (/api/v1/System/capabilities), both of which are domain functionality that also serve +/// the master/slave protocol. A healthy instance can be switched off by design, and a switched-on +/// instance can be unhealthy — so the two must never be conflated in monitoring. +/// +/// Every field is derived from in-process state. Nothing here may require a database query, +/// file read or network call. +/// +[ExcludeFromCodeCoverage] +public sealed record HealthReport( + string Status, + DateTimeOffset Timestamp, + string Version, + IReadOnlyList Modules); diff --git a/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs b/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs new file mode 100644 index 0000000..82d34cc --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs @@ -0,0 +1,41 @@ +using System.Text; +using Microsoft.IdentityModel.Tokens; +using SlpModularCms.Core.Identity.Models; + +namespace SlpModularCms.Core.Hosting; + +/// +/// Single source of the JWT validation parameters used across the application. +/// +/// +/// These parameters are consumed in two places: the JWT bearer authentication scheme, +/// and the availability gate's admin bypass (see IAdminTokenValidator). +/// +/// They MUST come from here rather than being configured separately in each place. +/// If the two ever drifted apart and the gate became the more permissive of the two, +/// a token the bearer scheme rejects could still bypass the availability gate — which +/// is exactly the defect the validated admin bypass was introduced to close. +/// +public static class JwtTokenValidation +{ + /// + /// Builds the validation parameters for the given settings. + /// + public static TokenValidationParameters Create(JwtSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + return new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = settings.Issuer, + ValidAudience = settings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Secret)), + // Exact expiry — a token is valid until its expiry moment and not a second longer. + ClockSkew = TimeSpan.Zero + }; + } +} diff --git a/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs new file mode 100644 index 0000000..c5c5108 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs @@ -0,0 +1,64 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.Tokens; + +namespace SlpModularCms.Core.Hosting.Security; + +/// +/// Validates a bearer token against the application's JWT validation parameters and checks +/// for an administrative role. +/// +/// +/// This replaces an earlier implementation that parsed the token with +/// JwtSecurityTokenHandler.ReadJwtToken — which reads claims WITHOUT verifying the +/// signature. Under that implementation an unauthenticated caller could present a self-made, +/// unsigned token carrying an Owner role claim and bypass the availability gate. Protected +/// endpoints still rejected such a caller, so no data was exposed, but the gate that suspends +/// a customer's site could be bypassed by anyone who knew the claim name. +/// +public sealed class AdminTokenValidator : IAdminTokenValidator +{ + private const string BearerPrefix = "Bearer "; + + private static readonly string[] AdminRoles = ["Owner", "Administrator"]; + + private readonly TokenValidationParameters _validationParameters; + private readonly JwtSecurityTokenHandler _handler = new(); + + public AdminTokenValidator(TokenValidationParameters validationParameters) + { + _validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters)); + } + + public bool IsVerifiedAdmin(string? authorizationHeader) + { + if (string.IsNullOrEmpty(authorizationHeader) || + !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var token = authorizationHeader[BearerPrefix.Length..].Trim(); + if (token.Length == 0) + { + return false; + } + + ClaimsPrincipal principal; + try + { + // Validates signature, issuer, audience and lifetime. A forged or expired token + // throws here and is treated as "not an admin" rather than as an error — deciding + // whether to serve is this component's job; returning 401 is not. + principal = _handler.ValidateToken(token, _validationParameters, out _); + } + catch (Exception) + { + return false; + } + + return AdminRoles.Any(role => principal.IsInRole(role)) + || principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value)) + || principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value)); + } +} diff --git a/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs new file mode 100644 index 0000000..705ee38 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs @@ -0,0 +1,24 @@ +namespace SlpModularCms.Core.Hosting.Security; + +/// +/// Decides whether a request carries a genuinely valid Owner or Administrator token. +/// +/// +/// Used by the availability gate, which runs before authentication middleware and therefore +/// cannot read HttpContext.User. Validation uses the same parameters as the JWT bearer +/// scheme (see ), so the gate can never be more permissive +/// than authentication itself. +/// +public interface IAdminTokenValidator +{ + /// + /// Returns true only when the supplied Authorization header contains a bearer token that + /// validates successfully and carries the Owner or Administrator role. + /// + /// Raw Authorization header value; may be null or empty. + /// + /// True when the caller is a verified Owner or Administrator; false in every other case, + /// including an absent, malformed, forged, expired or non-admin token. Never throws. + /// + bool IsVerifiedAdmin(string? authorizationHeader); +} diff --git a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs index f14cd30..f573781 100644 --- a/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs +++ b/src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs @@ -11,11 +11,11 @@ using Microsoft.IdentityModel.Tokens; using SlpModularCms.Core.Availability; using SlpModularCms.Core.Data; using SlpModularCms.Core.Exceptions; +using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Core.Identity.Authorization; using SlpModularCms.Core.Identity.Entities; using SlpModularCms.Core.Identity.Models; using SlpModularCms.Core.Identity.Services; -using System.Text; using System.Threading.RateLimiting; using System.Diagnostics.CodeAnalysis; @@ -56,6 +56,16 @@ public static class ServiceCollectionExtensions services.AddScoped(); // 4. Authentication + // + // The validation parameters are built once and shared: the bearer scheme below and the + // availability gate's admin bypass (IAdminTokenValidator) both use this same instance. + // Configuring them separately would allow the two to drift, and a gate more permissive + // than the bearer scheme would let a token that authentication rejects still bypass the + // availability gate. + var tokenValidationParameters = JwtTokenValidation.Create(jwtSettings); + services.AddSingleton(tokenValidationParameters); + services.AddSingleton(_ => new AdminTokenValidator(tokenValidationParameters)); + services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -63,17 +73,7 @@ public static class ServiceCollectionExtensions }) .AddJwtBearer(options => { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - ValidIssuer = jwtSettings.Issuer, - ValidAudience = jwtSettings.Audience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)), - ClockSkew = TimeSpan.Zero - }; + options.TokenValidationParameters = tokenValidationParameters; }); // 5. Authorization diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs index b1d651d..be14df0 100644 --- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs +++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs @@ -1,12 +1,9 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.IdentityModel.Tokens; using NSubstitute; using SlpModularCms.Core.Availability; +using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Modules.Availability.Middleware; using SlpModularCms.Modules.Availability.Services; @@ -16,6 +13,7 @@ public class AvailabilityMiddlewareMasterGateTests { private readonly IAvailabilityService _localSvc; private readonly IMasterAvailabilityService _masterSvc; + private readonly IAdminTokenValidator _adminTokenValidator; private readonly AvailabilityMiddleware _middleware; private readonly RequestDelegate _next; @@ -23,8 +21,12 @@ public class AvailabilityMiddlewareMasterGateTests { _localSvc = Substitute.For(); _masterSvc = Substitute.For(); + _adminTokenValidator = Substitute.For(); _next = Substitute.For(); - _middleware = new AvailabilityMiddleware(_next, NullLogger.Instance); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.Instance, + _adminTokenValidator); _localSvc.IsAvailableAsync().Returns(AvailabilityStatus.Available); _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(true, null)); @@ -108,7 +110,8 @@ public class AvailabilityMiddlewareMasterGateTests public async Task InvokeAsync_BypassesBothGates_WhenAdminJwtPresent() { var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}"; + context.Request.Headers.Authorization = "Bearer owner-token"; + _adminTokenValidator.IsVerifiedAdmin("Bearer owner-token").Returns(true); _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null)); _localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); @@ -119,11 +122,12 @@ public class AvailabilityMiddlewareMasterGateTests } [Fact] - public async Task InvokeAsync_DoesNotBypass_WhenUserRoleJwtAndMasterBlocks() + public async Task InvokeAsync_DoesNotBypass_WhenNonAdminJwtAndMasterBlocks() { var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); - context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}"; + context.Request.Headers.Authorization = "Bearer user-token"; + _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false); _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null)); await _middleware.InvokeAsync(context, _localSvc, _masterSvc); @@ -132,14 +136,17 @@ public class AvailabilityMiddlewareMasterGateTests context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); } - private static string CreateJwtWithRole(string role) + [Fact] + public async Task InvokeAsync_BypassesBothGates_ForHealthEndpoint() { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!")); - var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var token = new JwtSecurityToken( - claims: [new Claim(ClaimTypes.Role, role)], - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: creds); - return new JwtSecurityTokenHandler().WriteToken(token); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; + _masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null)); + _localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + + await _middleware.InvokeAsync(context, _localSvc, _masterSvc); + + await _next.Received(1).Invoke(context); + _masterSvc.DidNotReceive().GetMasterStatus(); } } diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs index 9908df3..d5b2556 100644 --- a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs +++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs @@ -7,6 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IdentityModel.Tokens; using NSubstitute; using SlpModularCms.Core.Availability; +using SlpModularCms.Core.Hosting; +using SlpModularCms.Core.Hosting.Security; +using SlpModularCms.Core.Identity.Models; using SlpModularCms.Modules.Availability.Middleware; using SlpModularCms.Modules.Availability.Services; using Xunit; @@ -17,6 +20,7 @@ public class AvailabilityMiddlewareTests { private readonly IAvailabilityService _service; private readonly IMasterAvailabilityService _masterService; + private readonly IAdminTokenValidator _adminTokenValidator; private readonly AvailabilityMiddleware _middleware; private readonly RequestDelegate _next; @@ -24,8 +28,12 @@ public class AvailabilityMiddlewareTests { _service = Substitute.For(); _masterService = Substitute.For(); + _adminTokenValidator = Substitute.For(); _next = Substitute.For(); - _middleware = new AvailabilityMiddleware(_next, NullLogger.Instance); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.Instance, + _adminTokenValidator); // Master gate passes by default in these local gate tests _masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null)); @@ -91,6 +99,34 @@ public class AvailabilityMiddlewareTests await _next.Received(1).Invoke(context); } + /// + /// Infrastructure liveness must survive the CMS being switched off. A deliberately disabled + /// instance is still perfectly healthy, and monitoring must not report it as down. + /// + [Fact] + public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenSystemUnavailable() + { + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; + _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + + await _middleware.InvokeAsync(context, _service, _masterService); + + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenMasterGateClosed() + { + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; + _masterService.GetMasterStatus().Returns(new MasterGateStatus(false, "Disabled by master")); + + await _middleware.InvokeAsync(context, _service, _masterService); + + await _next.Received(1).Invoke(context); + } + [Fact] public async Task InvokeAsync_ShouldBlockRequest_WhenSystemInMaintenance() { @@ -105,10 +141,11 @@ public class AvailabilityMiddlewareTests } [Fact] - public async Task InvokeAsync_ShouldAllowAdminBypass_WhenOwnerToken() + public async Task InvokeAsync_ShouldAllowAdminBypass_WhenValidatorAcceptsTheToken() { var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}"; + context.Request.Headers.Authorization = "Bearer some-token"; + _adminTokenValidator.IsVerifiedAdmin("Bearer some-token").Returns(true); _service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance); await _middleware.InvokeAsync(context, _service, _masterService); @@ -117,23 +154,12 @@ public class AvailabilityMiddlewareTests } [Fact] - public async Task InvokeAsync_ShouldAllowAdminBypass_WhenAdministratorToken() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}"; - _service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance); - - await _middleware.InvokeAsync(context, _service, _masterService); - - await _next.Received(1).Invoke(context); - } - - [Fact] - public async Task InvokeAsync_ShouldNotBypass_WhenUserRoleToken() + public async Task InvokeAsync_ShouldNotBypass_WhenValidatorRejectsTheToken() { var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); - context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}"; + context.Request.Headers.Authorization = "Bearer some-token"; + _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false); _service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance); await _middleware.InvokeAsync(context, _service, _masterService); @@ -147,6 +173,7 @@ public class AvailabilityMiddlewareTests { var context = new DefaultHttpContext(); context.Response.Body = new MemoryStream(); + _adminTokenValidator.IsVerifiedAdmin(Arg.Any()).Returns(false); _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); await _middleware.InvokeAsync(context, _service, _masterService); @@ -154,28 +181,80 @@ public class AvailabilityMiddlewareTests await _next.DidNotReceive().Invoke(Arg.Any()); } - [Fact] - public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken() + /// + /// Wires the middleware to the real validator instead of a substitute, so the two are proven + /// to fit together. A substitute alone would keep passing even if the middleware were wired + /// back to unvalidated token parsing. + /// + public class WithRealValidator { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - context.Request.Headers.Authorization = "Bearer not-a-valid-jwt"; - _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!"; + private const string Issuer = "SlpModularCms"; + private const string Audience = "SlpModularCmsPortal"; - await _middleware.InvokeAsync(context, _service, _masterService); + private readonly IAvailabilityService _service = Substitute.For(); + private readonly IMasterAvailabilityService _masterService = Substitute.For(); + private readonly RequestDelegate _next = Substitute.For(); + private readonly AvailabilityMiddleware _middleware; - await _next.DidNotReceive().Invoke(Arg.Any()); - } + public WithRealValidator() + { + var parameters = JwtTokenValidation.Create(new JwtSettings + { + Secret = Secret, + Issuer = Issuer, + Audience = Audience + }); - private static string CreateJwtWithRole(string role) - { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!")); - var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var token = new JwtSecurityToken( - claims: [new Claim(ClaimTypes.Role, role)], - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: creds - ); - return new JwtSecurityTokenHandler().WriteToken(token); + _middleware = new AvailabilityMiddleware( + _next, + NullLogger.Instance, + new AdminTokenValidator(parameters)); + + _masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null)); + } + + [Fact] + public async Task InvokeAsync_ShouldNotBypass_ForForgedUnsignedOwnerToken() + { + // The defect this unit fixes: an unsigned token carrying an Owner claim used to pass. + var forged = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: [new Claim(ClaimTypes.Role, "Owner")], + expires: DateTime.UtcNow.AddHours(1))); + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.Request.Headers.Authorization = $"Bearer {forged}"; + _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + + await _middleware.InvokeAsync(context, _service, _masterService); + + await _next.DidNotReceive().Invoke(Arg.Any()); + context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + } + + [Fact] + public async Task InvokeAsync_ShouldStillBypass_ForGenuineOwnerToken() + { + // Preserved behaviour: an administrator can always reach a disabled instance to + // switch it back on. + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)); + var genuine = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: [new Claim(ClaimTypes.Role, "Owner")], + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256))); + + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = $"Bearer {genuine}"; + _service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable); + + await _middleware.InvokeAsync(context, _service, _masterService); + + await _next.Received(1).Invoke(context); + } } } diff --git a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs index 0ba0cac..3bd734d 100644 --- a/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs +++ b/src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs @@ -1,9 +1,8 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using SlpModularCms.Core.Availability; +using SlpModularCms.Core.Hosting.Security; using SlpModularCms.Modules.Availability.Services; namespace SlpModularCms.Modules.Availability.Middleware; @@ -12,11 +11,16 @@ public class AvailabilityMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; + private readonly IAdminTokenValidator _adminTokenValidator; - public AvailabilityMiddleware(RequestDelegate next, ILogger logger) + public AvailabilityMiddleware( + RequestDelegate next, + ILogger logger, + IAdminTokenValidator adminTokenValidator) { _next = next; _logger = logger; + _adminTokenValidator = adminTokenValidator; } // Paths that are always accessible regardless of system availability. @@ -25,6 +29,9 @@ public class AvailabilityMiddleware // Master endpoints bypass so master can always push status or re-register. // SlaveStatus bypasses so a slave can always pull the master's status, even if the // master instance is (for whatever reason) reporting itself as locally unavailable. + // /health bypasses because it reports infrastructure liveness, which is a different + // question from whether the CMS is switched on: an instance that is deliberately + // disabled is still perfectly healthy, and must not be reported as down. private static readonly string[] _bypassPrefixes = [ "/api/v1/Availability/status", @@ -32,6 +39,7 @@ public class AvailabilityMiddleware "/api/v1/Setup/status", "/api/v1/master/", "/api/v1/SlaveStatus", + "/health", ]; public async Task InvokeAsync( @@ -89,26 +97,18 @@ public class AvailabilityMiddleware }); } + /// + /// Lets a verified Owner or Administrator through the gate, so administrators can always + /// reach a disabled instance to switch it back on. + /// + /// + /// The token is fully validated — signature, issuer, audience and lifetime — against the + /// same parameters as the JWT bearer scheme. An earlier implementation read the claims + /// without verifying the signature, which meant an unauthenticated caller could present a + /// self-made token carrying an Owner role claim and bypass the gate. + /// private bool IsAdminBypass(HttpContext context) { - var authHeader = context.Request.Headers.Authorization.ToString(); - if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - try - { - var tokenString = authHeader.Substring("Bearer ".Length); - var handler = new JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(tokenString); - - var roles = token.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value); - return roles.Any(r => r == "Owner" || r == "Administrator"); - } - catch (Exception) - { - return false; - } + return _adminTokenValidator.IsVerifiedAdmin(context.Request.Headers.Authorization.ToString()); } } -- 2.39.5 From 5f3eda2680c5b35fb062226be510cd21160b5e8d Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 28 Jul 2026 00:00:45 +0200 Subject: [PATCH 03/35] Makes a redeploy safe for the key ring and the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing here is visible in normal operation. Its whole purpose is that swapping the release directory on deploy cannot silently destroy state. Data Protection secures the API keys that authenticate master/slave communication. Two separate defaults would each have destroyed them: keys are held on the filesystem, which a release swap discards, and the application discriminator is derived from the content root path, which changes with every release directory — so even keys stored in a database would have stopped being derivable. Keys now live in ApplicationDbContext and the discriminator is a fixed constant. Losing them produces no error. It produces stored keys that no longer decrypt, which presents as an apparent network fault between a Master and its slaves and is easily misdiagnosed. That is also why the tests assert the resulting configuration rather than the registration: the XmlRepository must be the EF one and the discriminator must be the constant, plus a round-trip proving a value encrypted before a deploy is readable after one. A test that only checked "Data Protection is registered" would have passed in the broken case too. Both modules previously called AddDataProtection() themselves. Module registration runs after the host's, so those calls re-registered the configuration chain and would have overridden the persistent store while IDataProtector still resolved. They are removed, with a comment at each site — the deletion otherwise looks like a regression. Each module's own test project now guards against it being reintroduced. ApplicationDbContext also migrates itself at startup. Deploy targets offer no CLI, so migrations cannot be a manual step on the server. Failures are classified rather than treated alike: a connection failure means the database is not up yet, normal when the app and the database start together after a reboot, and is retried with backoff; a migration failure means something is broken and fails at once. Either way the process does not start, which is what makes the liveness health check trustworthy — an application that cannot reach its schema never answers /health, so monitoring goes red instead of reporting a healthy instance that cannot serve a request. The cost of migrating without a human gate is that migrations must stay forward-compatible and non-destructive, since rollback is "redeploy the previous release". The new migration is purely additive. Also wires this and the preceding hosting commit into both hosts, as they touch the same lines of Program.cs. Two constraints are enforced by documentation rather than code, and belong in the deployment instructions: the key table must never be pruned, and only one instance may migrate a given database at a time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw --- ...u2-data-durability-code-generation-plan.md | 145 ++++++ .../plans/u2-data-durability-fd-questions.md | 76 +++ ...-data-durability-functional-design-plan.md | 42 ++ .../code/generation-summary.md | 87 ++++ .../functional-design/business-logic-model.md | 194 ++++++++ .../functional-design/business-rules.md | 130 +++++ .../functional-design/domain-entities.md | 124 +++++ src/SlpModularCms.Api.Slave/Program.cs | 13 + src/SlpModularCms.Api/Program.cs | 31 +- .../Hosting/DataProtectionExtensionsTests.cs | 97 ++++ .../DatabaseMigrationExtensionsTests.cs | 95 ++++ .../Data/ApplicationDbContext.cs | 15 +- .../Hosting/DataProtectionExtensions.cs | 58 +++ .../Hosting/DatabaseMigrationExtensions.cs | 112 +++++ ...27203036_AddDataProtectionKeys.Designer.cs | 452 ++++++++++++++++++ .../20260727203036_AddDataProtectionKeys.cs | 35 ++ .../ApplicationDbContextModelSnapshot.cs | 21 +- .../SlpModularCms.Core.csproj | 7 + .../AvailabilityModuleDataProtectionTests.cs | 52 ++ .../AvailabilityModule.cs | 7 +- .../MasterModuleDataProtectionTests.cs | 45 ++ .../MasterModule.cs | 5 +- 22 files changed, 1833 insertions(+), 10 deletions(-) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-code-generation-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-fd-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-functional-design-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-logic-model.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-rules.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/domain-entities.md create mode 100644 src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs create mode 100644 src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs create mode 100644 src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs create mode 100644 src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs create mode 100644 src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.Designer.cs create mode 100644 src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs create mode 100644 src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs create mode 100644 src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-code-generation-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-code-generation-plan.md new file mode 100644 index 0000000..fb5eeff --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-code-generation-plan.md @@ -0,0 +1,145 @@ +# Code Generation Plan — U2 Data Durability + +**This plan is the single source of truth for Code Generation of U2.** Generation executes exactly these steps in order; no step is added or skipped during execution. + +--- + +## Unit Context + +| Aspect | Detail | +|---|---| +| **Unit** | U2 Data Durability | +| **Round** | R1 (with U1 Hosting & Serving) | +| **Workspace root** | `K:\Development\Projects\SlpModularCms` | +| **Project type** | Brownfield — existing structure retained, files modified in place | +| **Requirements** | FR-11, FR-12 | +| **Components** | C-05 Data Protection, C-06 keys table, C-07 migration runner, U2 portion of C-16 | +| **Business rules** | BR-U2-01 … BR-U2-18 | +| **Depends on** | Nothing. U1 and U2 are mutually independent | +| **Depended on by** | U6 — durability must land before the first automated deploy | +| **New database entities** | One: the Data Protection keys table, owned by `ApplicationDbContext` | + +### Requirement traceability + +| Requirement | Implemented by steps | +|---|---| +| FR-11 — automatic `ApplicationDbContext` migration at startup | 4, 5, 6 | +| FR-12 — persistent Data Protection key ring | 1, 2, 3, 5, 6 | + +### Why this unit exists + +Nothing here is user-visible. Its whole purpose is that U6's atomic release switch **cannot** silently destroy schema state or Master↔slave trust. Two failure modes are being closed, both of which would otherwise appear only after the first production deploy and present as something else entirely. + +--- + +## Generation Steps + +### Step 1: Package reference +- [x] Modify `src/SlpModularCms.Core/SlpModularCms.Core.csproj` to add `Microsoft.AspNetCore.DataProtection.EntityFrameworkCore` version **10.0.9**, matching the existing 10.0.x line + +### Step 2: Keys table on the Core context +- [x] Modify `src/SlpModularCms.Core/Data/ApplicationDbContext.cs`: + - [x] Implement `IDataProtectionKeyContext` + - [x] Add `DbSet DataProtectionKeys` + - [x] Leave every existing entity configuration untouched + +### Step 3: Data Protection registration +- [x] Create `src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs` with `AddCmsDataProtection()` +- [x] Configure `PersistKeysToDbContext()` (BR-U2-01) +- [x] Set the application discriminator to a **fixed constant in code** (BR-U2-02) — not configurable, not derived from any path +- [x] Leave key lifetime at the framework default of 90 days (BR-U2-05) +- [x] Document in code why the discriminator is a constant: the default derives from the content root path, which changes on every atomic release switch + +### Step 4: Startup migration runner +- [x] Create `src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs` with `MigrateCoreDatabase()` +- [x] Apply `ApplicationDbContext` migrations before the application accepts traffic (BR-U2-09) +- [x] Classify failures (BR-U2-11, BR-U2-12): retry **connection** failures with increasing delay up to a bounded number of attempts; fail **migration** failures immediately with no retry +- [x] Log the reason before failing, with diagnostic context but **no** connection string, credentials or secrets (BR-U2-14) +- [x] Propagate the exception when retries are exhausted or the failure is a migration failure, so the process does not start (BR-U2-13) + +### Step 5: Remove the conflicting module registrations +- [x] Modify `src/SlpModularCms.Modules.Availability/AvailabilityModule.cs` — remove `services.AddDataProtection()` +- [x] Modify `src/SlpModularCms.Modules.Master/MasterModule.cs` — remove `services.AddDataProtection()` +- [x] Leave every other registration in both modules unchanged; they continue consuming `IDataProtector` (BR-U2-04) + +*This is the § 5.1 conflict. Module registration runs **after** the host's, so these bare calls would override the persistent key store. `IDataProtector` resolves either way, so the defect would surface only after the first release switch as slave API keys that no longer decrypt — presenting as a network fault between Master and slave.* + +### Step 6: Host composition +- [x] Modify `src/SlpModularCms.Api/Program.cs` — call `AddCmsDataProtection()` **before** `orchestrator.RegisterModuleServices(...)` (BR-U2-03), and `MigrateCoreDatabase()` after `builder.Build()` and before `orchestrator.UseModules(app)` (BR-U2-10) +- [x] Modify `src/SlpModularCms.Api.Slave/Program.cs` — the same two calls in the same positions + +### Step 7: Core migration +- [x] Generate the EF Core migration for the keys table into `src/SlpModularCms.Core/Migrations/` +- [x] Verify the migration is purely additive — no dropped or narrowed columns, so redeploying an earlier release stays safe (BR-U2-16) + +### Step 8: Data Protection unit tests +- [x] Create `src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs`: + - [x] The persistent key store **survives module registration** — the highest-value assertion in this unit, since registration alone passes in both the broken and fixed cases + - [x] The application discriminator is the fixed constant, not a path-derived value + - [x] A protected value round-trips across a simulated content-root change + - [x] Neither module registers Data Protection, so the conflict cannot be reintroduced by a future change + +### Step 9: Migration runner unit tests +- [x] Create `src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs`: + - [x] A connection failure is retried + - [x] A migration failure is **not** retried and fails immediately + - [x] Retry exhaustion propagates + - [x] Failure logging contains no connection string or credentials + +### Step 10: Documentation +- [x] Create `aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md` — files created and modified, decisions taken, and any deviation from this plan +- [x] Record the two operational constraints that are enforced by documentation rather than code, for later inclusion in the Operations deployment instructions: the keys table must **never** be pruned (BR-U2-06), and only one instance may migrate a given database at a time (BR-U2-17) + +### Step 11: Build and test verification (automatic) +- [x] `dotnet build SlpModularCms.sln -c Release` +- [x] `dotnet test` for `SlpModularCms.Core.Tests`, `SlpModularCms.Modules.Availability.Tests` and `SlpModularCms.Modules.Master.Tests` +- [x] Fix any failure directly and re-run until green +- [x] Record the outcome for the completion message + +--- + +## Files Touched + +### Created +| Path | Purpose | +|---|---| +| `src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs` | Persistent key ring registration | +| `src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs` | Startup migration with failure classification | +| `src/SlpModularCms.Core/Migrations/*_AddDataProtectionKeys.cs` | Keys table migration | +| `src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs` | Tests | +| `src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs` | Tests | + +### Modified +| Path | Change | +|---|---| +| `src/SlpModularCms.Core/SlpModularCms.Core.csproj` | Add the Data Protection EF Core package | +| `src/SlpModularCms.Core/Data/ApplicationDbContext.cs` | Implement `IDataProtectionKeyContext`, add the keys set | +| `src/SlpModularCms.Modules.Availability/AvailabilityModule.cs` | Remove `AddDataProtection()` | +| `src/SlpModularCms.Modules.Master/MasterModule.cs` | Remove `AddDataProtection()` | +| `src/SlpModularCms.Api/Program.cs` | Data Protection registration and startup migration | +| `src/SlpModularCms.Api.Slave/Program.cs` | Same | + +**Brownfield rule**: every file above that exists is modified in place. No parallel copies. + +--- + +## Risk Notes for the Executor + +| Risk | Mitigation in this plan | +|---|---| +| A test that merely asserts "Data Protection is registered" passes in both the broken and fixed cases | Step 8 asserts the **resulting configuration**, not the registration | +| The discriminator silently reverting to the path-derived default | Step 8 asserts the constant explicitly | +| The new migration being non-additive and breaking rollback | Step 7 verifies additivity | +| Startup migration masking a genuine migration fault by retrying it | Step 4 classifies failures; Step 9 asserts the classification | +| Both hosts must still start | Step 11 builds the whole solution; the composed startup is verified at the phase-level Build and Test stage, where the Slave — which has no test project — is started | + +--- + +## Out of Scope for U2 + +- Static content, health endpoint, availability-gate changes — U1 +- Security headers — U3 +- Sentry, Umami, frontend configuration — U4 +- Anything under `.gitea/` — U5 and U6 +- Certificate-based key encryption — deferred as DEV-05 follow-up +- A distributed migration lock — Q4 = C, handled by documented operational constraint diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-fd-questions.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-fd-questions.md new file mode 100644 index 0000000..6bb4ded --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-fd-questions.md @@ -0,0 +1,76 @@ +# Functional Design Questions — U2 Data Durability + +Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past. + +--- + +## Question 1 — Waar komt de application discriminator vandaan? + +**Context**: de application discriminator bepaalt of twee processen dezelfde Data Protection-sleutels kunnen gebruiken. Standaard leidt ASP.NET Core hem af uit het content root-pad — en dat verandert bij élke atomaire release-switch. Zonder expliciete waarde is de key ring dus alsnog effectief weg na een deploy, ondanks dat hij in de database staat. + +Hij moet dus vast staan. De vraag is waar die waarde vandaan komt. + +A) Een vaste constante in de code (bijv. `"SlpModularCms"`) — kan niet per ongeluk verkeerd gezet worden, en is voor alle instanties gelijk +B) Uit configuratie, met een vaste standaardwaarde — dan kun je per klant/instantie een eigen waarde zetten als dat ooit nodig is +C) Uit configuratie, verplicht in te vullen — dwingt een bewuste keuze af per omgeving +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:A + +--- + +## Question 2 — Moeten de sleutels versleuteld in de database staan? + +**Context**: `PersistKeysToDbContext` slaat de sleutels standaard **onversleuteld** op als XML in de tabel. Wie de database kan lezen, kan daarmee de opgeslagen slave-API-keys ontsleutelen. + +Op Windows lost DPAPI dit normaal op, maar dat werkt niet op Linux (de Pi), dus dat is hier geen optie. Het alternatief is versleutelen met een X.509-certificaat — maar dan moet dat certificaat mee gedeployed worden en beschikbaar blijven, wat een nieuwe versie van hetzelfde probleem introduceert: raak je het certificaat kwijt, dan zijn de sleutels alsnog onleesbaar. + +SECURITY-01 vraagt om versleuteling at rest. + +A) Onversleuteld in de database, en de encryptie-at-rest van de database zelf is de maatregel — vastleggen als bewuste onderbouwde keuze, met de eis dat de databaseverbinding TLS gebruikt en de database niet publiek benaderbaar is +B) Versleutelen met een X.509-certificaat — sterker, maar verplaatst het bewaarprobleem naar het certificaat en voegt een deploystap toe +C) Onversleuteld nu, en certificaat-encryptie als apart vervolgpunt vastleggen +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:C + +--- + +## Question 3 — Wat gebeurt er als de database bij het opstarten net niet bereikbaar is? + +**Context**: je koos fail fast bij een migratiefout. Maar er is een verschil tussen "de migratie klopt niet" (echt fout) en "de database is er nog even niet" (tijdelijk) — bijvoorbeeld als de app en de SQL Server-container tegelijk opstarten na een herstart van de Pi. + +Bij strikte fail-fast start de app dan niet, en moet iets anders hem opnieuw starten. + +A) Strikt fail fast, geen retry — de procesmanager (systemd) herstart de app toch al automatisch, dus dat lost het vanzelf op +B) Een korte retry met toenemende wachttijd (bijv. 5 pogingen over ~30 seconden) en dán pas falen — vangt het opstartvenster af zonder een echte fout te verbergen +C) Retry alleen bij verbindingsfouten, direct falen bij een migratiefout — onderscheid tussen "nog niet bereikbaar" en "kapot" +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:C + +--- + +## Question 4 — Wat als twee instanties tegelijk opstarten en migreren? + +**Context**: `Database.Migrate()` is niet ontworpen om veilig gelijktijdig te draaien. In jouw huidige opzet draait er één instantie per omgeving, dus dit speelt nu niet. Maar de master/slave-opzet betekent dat er meerdere instanties naar **verschillende** databases wijzen, en een herstart kan ze wel gelijktijdig laten opstarten. + +A) Negeren — één instantie per database, dus dit kan niet voorkomen. Wel als aanname vastleggen +B) Een migratielock in de database gebruiken zodat gelijktijdig migreren veilig is — robuuster, maar meer complexiteit voor een situatie die zich nu niet voordoet +C) Alleen documenteren in de deployment-instructies dat instanties niet gelijktijdig gemigreerd moeten worden +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:C + +--- + +## Question 5 — Sleutellevensduur en rotatie + +**Context**: standaard maakt Data Protection elke 90 dagen een nieuwe sleutel aan en houdt oude sleutels beschikbaar om bestaande waarden te kunnen blijven ontsleutelen. Voor de versleutelde slave-API-keys betekent dat: die blijven leesbaar, ook na rotatie, zolang de oude sleutels in de tabel blijven staan. + +A) De standaard van 90 dagen aanhouden en oude sleutels nooit opruimen — bestaande waarden blijven altijd leesbaar +B) Een langere levensduur instellen zodat er minder sleutels ontstaan +C) De standaard aanhouden, plus expliciet vastleggen in de documentatie dat de sleuteltabel nooit opgeschoond mag worden — want dat zou de opgeslagen API-keys onleesbaar maken +X) Anders (beschrijf hieronder na de [Answer]:-tag) + +[Answer]:C diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-functional-design-plan.md b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-functional-design-plan.md new file mode 100644 index 0000000..1385bf8 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/plans/u2-data-durability-functional-design-plan.md @@ -0,0 +1,42 @@ +# Functional Design Plan — U2 Data Durability + +**Unit**: U2 Data Durability +**Round**: R1 (with U1 Hosting & Serving) +**Requirements**: FR-11, FR-12 +**Components**: C-05, C-06, C-07, U2 portion of C-16 + +--- + +## Step 1: Analyze unit context +- [x] Read the U2 definition from `unit-of-work.md` +- [x] Read the requirement assignment from `unit-of-work-story-map.md` +- [x] Read the carried-in design items — § 5.1 duplicate registration conflict, explicit application discriminator + +## Step 2: Design the Data Protection key ring +- [x] Define the key-storage entity and its owning context +- [x] Define the application-discriminator source and stability guarantee +- [x] Define key encryption at rest +- [x] Define key lifetime and rotation behaviour +- [x] Define the registration-order rule that resolves the duplicate-registration conflict + +## Step 3: Design startup migration behaviour +- [x] Define which contexts migrate and in what order +- [x] Define failure behaviour and what is logged before failing +- [x] Define behaviour when the database is temporarily unreachable at startup +- [x] Define behaviour when a migration is applied concurrently by two starting instances + +## Step 4: Define business rules +- [x] Enumerate key-ring durability rules +- [x] Enumerate migration rules +- [x] Identify error and edge-case scenarios + +## Step 5: Design verification approach +- [x] Define how "the persistent key store survives module registration" is asserted +- [x] Define how discriminator stability across a content-root change is asserted + +## Step 6: Generate artifacts +- [x] Generate `business-logic-model.md` +- [x] Generate `business-rules.md` +- [x] Generate `domain-entities.md` +- [x] Validate all diagrams against the Mermaid standards +- [x] Verify Security Baseline compliance for this unit's design diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md new file mode 100644 index 0000000..209fed3 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/code/generation-summary.md @@ -0,0 +1,87 @@ +# Code Generation Summary — U2 Data Durability + +**Date**: 2026-07-27 +**Requirements**: FR-11, FR-12 + +--- + +## Files Created + +| Path | Purpose | +|---|---| +| `src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs` | `AddCmsDataProtection()` — database key ring, fixed discriminator | +| `src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs` | `MigrateCoreDatabase()` — startup migration with failure classification | +| `src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs` | Keys table migration | +| `src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs` | 4 tests | +| `src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs` | 7 tests | +| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs` | 1 test | +| `src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs` | 1 test | + +## Files Modified + +| Path | Change | +|---|---| +| `src/SlpModularCms.Core/SlpModularCms.Core.csproj` | `Microsoft.AspNetCore.DataProtection.EntityFrameworkCore` 10.0.9 | +| `src/SlpModularCms.Core/Data/ApplicationDbContext.cs` | Implements `IDataProtectionKeyContext`; `DataProtectionKeys` set | +| `src/SlpModularCms.Modules.Availability/AvailabilityModule.cs` | `AddDataProtection()` removed | +| `src/SlpModularCms.Modules.Master/MasterModule.cs` | `AddDataProtection()` removed | +| `src/SlpModularCms.Api/Program.cs` | `AddCmsDataProtection()` before module registration; `MigrateCoreDatabase()` after build | +| `src/SlpModularCms.Api.Slave/Program.cs` | Same | + +No duplicate or parallel files were created. + +--- + +## Implementation Decisions + +### The removals are the point of this unit, so they are commented in place +Deleting `services.AddDataProtection()` from two modules looks like a regression to anyone who does not know the ordering issue. Both call sites therefore carry a comment explaining that the host owns Data Protection and that a bare call here would silently discard the persistent key store. + +### The tests assert configuration, not registration +A test asserting "Data Protection is registered" passes in both the broken and fixed cases, because `IDataProtector` resolves either way. Every test here inspects the **resulting** configuration instead: + +- `KeyManagementOptions.XmlRepository` is `EntityFrameworkCoreXmlRepository` — not the filesystem default +- `DataProtectionOptions.ApplicationDiscriminator` is the fixed constant — not the path-derived default +- A protected value survives a simulated restart from a different release directory, which is the property that actually matters +- The discriminator contains no path separator, guarding against a future "improvement" that makes it computed or configurable + +The module-level tests live in each module's own test project rather than in `Core.Tests`, because `Core.Tests` does not reference the modules. Each registers the host's Data Protection first and the module second — the real ordering — and asserts the EF repository survives. + +### `SqlException` is produced genuinely, not faked +`SqlException` has no public constructor. Rather than substituting a stand-in type, the test provokes a real one by opening a connection to an unreachable host with a one-second timeout. The classifier is therefore exercised against the exact type it will meet in production. + +### Wrong credentials are classified as a connection failure +Distinguishing bad credentials from an unreachable server would add branching for no benefit: retries are exhausted and the process does not start either way. The simpler classification is the honest one. + +### Migration verified as additive +The generated migration only creates a table — no dropped or narrowed columns. Rollback by redeploying an earlier release therefore stays safe, which BR-U2-16 requires and which the whole rollback strategy depends on. + +--- + +## Operational Constraints Enforced by Documentation, Not Code + +Both were decided deliberately (U2 FD Q4 = C, Q5 = C). They must appear in the Operations deployment instructions: + +| Constraint | Why it is not enforced in code | +|---|---| +| **The `DataProtectionKeys` table must never be pruned.** Deleting a key makes every value encrypted with it permanently unreadable, including stored slave API keys. | Nothing in the application deletes these rows; the risk comes from a human treating the table as housekeeping. It is the single most destructive maintenance action available against this system, and it looks harmless. | +| **Only one instance may migrate a given database at a time.** | One instance per database holds by design today — the Master and each slave have their own. A distributed migration lock would add failure modes without removing any. **If the deployment model ever changes to multiple instances sharing a database, automatic startup migration must be revisited before that change is made.** | + +Also recorded for Operations: **DEV-05** — keys are stored unencrypted at rest, with TLS on the database connection and a non-public database as the compensating controls (BR-U2-08). These are not optional extras; they are what makes the deviation acceptable. + +--- + +## Verification + +| Check | Result | +|---|---| +| `dotnet build SlpModularCms.sln -c Release` | ✅ 0 errors | +| `SlpModularCms.Core.Tests` | ✅ 83 passed | +| `SlpModularCms.Modules.Availability.Tests` | ✅ 82 passed | +| `SlpModularCms.Modules.Identity.Tests` | ✅ 37 passed | +| `SlpModularCms.Modules.Master.Tests` | ✅ 51 passed | +| Migration is purely additive | ✅ Inspected — creates one table, drops nothing | + +No failures occurred during generation. + +**Not verifiable at this stage**: the composed startup path (`MigrateCoreDatabase` against a real database, and both hosts actually starting) requires SQL Server. Carried to the phase-level Build and Test stage. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-logic-model.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-logic-model.md new file mode 100644 index 0000000..5268fba --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-logic-model.md @@ -0,0 +1,194 @@ +# Business Logic Model — U2 Data Durability + +**Unit**: U2 Data Durability +**Requirements**: FR-11, FR-12 + +--- + +## 1. Scope of the Logic + +U2 delivers nothing a user can see. Its entire value is negative: after this unit, a redeploy **cannot** silently destroy schema state or the trust relationship between a Master and its slaves. + +Two mechanisms: +1. **Key ring durability** — Data Protection keys move from the filesystem (discarded by every atomic release switch) into the database, with an application discriminator that does not change when the release directory does +2. **Schema convergence** — `ApplicationDbContext` migrates itself at startup, so a deployment needs no CLI access to the host + +Both are startup-time concerns. Neither participates in request handling. + +--- + +## 2. Startup Sequence + +```mermaid +graph TD + boot["Host builder starts"] + log["Configure logging and Sentry"] + disc["Discover modules"] + core["AddCoreInfrastructure"] + dp["AddCmsDataProtection
persistent key store plus
fixed application discriminator"] + mods["Module RegisterServices
AddDataProtection removed from both"] + build["Build application"] + mig["Migrate ApplicationDbContext"] + classify{"Failure type ?"} + retry["Retry with backoff"] + fail["Propagate: process does not start"] + usemods["UseModules
module contexts migrate"] + serve["Accept traffic"] + + boot --> log + log --> disc + disc --> core + core --> dp + dp --> mods + mods --> build + build --> mig + mig -->|success| usemods + mig -->|failure| classify + classify -->|"connection failure"| retry + classify -->|"migration failure"| fail + retry -->|"attempts remain"| mig + retry -->|"attempts exhausted"| fail + usemods --> serve + + classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000; + classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef critical fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; + class boot entry; + class log,disc,core,mods,build,usemods,serve step; + class dp,mig,classify,retry critical; + class fail bad; +``` + +Text alternative: Data Protection is configured with a persistent store before module registration, the Core context migrates before the module contexts, and a migration failure is classified — connection failures are retried with backoff while genuine migration failures stop the process immediately. + +--- + +## 3. The Registration-Order Conflict + +This is the unit's most important piece of logic, and it is a *removal* rather than an addition. + +`AvailabilityModule.RegisterServices` and `MasterModule.RegisterServices` each call `services.AddDataProtection()` today. Module registration runs **after** the host's registration. In ASP.NET Core, a later `AddDataProtection()` re-registers the configuration chain, so the modules' bare calls would discard the persistent key store configured by the host. + +```mermaid +graph TD + subgraph broken["Without the fix"] + h1["Host: AddCmsDataProtection
persistent store configured"] + m1["AvailabilityModule: AddDataProtection"] + m2["MasterModule: AddDataProtection"] + r1["Result: filesystem key ring
FR-12 silently ineffective"] + h1 --> m1 + m1 --> m2 + m2 --> r1 + end + + subgraph fixed["With the fix"] + h2["Host: AddCmsDataProtection
persistent store configured"] + m3["Modules: no Data Protection call
they consume IDataProtector only"] + r2["Result: database key ring
survives release switches"] + h2 --> m3 + m3 --> r2 + end + + classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000; + classDef neutral fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + class h1,h2,m3 neutral; + class m1,m2,r1 bad; + class r2 good; +``` + +Text alternative: leaving the modules' own Data Protection calls in place would override the host's persistent key store and leave the key ring on the filesystem, whereas removing them lets the host's single configuration stand. + +**Why this is dangerous rather than merely wrong**: registration tests pass either way — `IDataProtector` resolves in both cases. The defect appears only after the first atomic release switch, as slave API keys that no longer decrypt, presenting as a network fault between Master and slave. The verification for this unit must therefore assert the **resulting configuration**, not merely that Data Protection is registered. + +--- + +## 4. Application Discriminator Stability + +The application discriminator determines whether two processes derive the same keys. By default it is derived from the content root path — which changes on every atomic release switch. Persisting keys in the database while letting the discriminator move would produce keys that are stored but unusable: a second, quieter version of the same failure. + +Per Q1 = A the discriminator is a **fixed constant in code**. + +```mermaid +graph TD + r1["Release directory 1
content root /srv/cms/releases/001"] + r2["Release directory 2
content root /srv/cms/releases/002"] + disc["Fixed discriminator constant"] + keys[("Key ring in database")] + same["Same keys derived
stored values stay readable"] + + r1 --> disc + r2 --> disc + disc --> keys + keys --> same + + classDef release fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef fixed fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef store fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + class r1,r2 release; + class disc fixed; + class keys store; + class same good; +``` + +Text alternative: two different release directories both use the same fixed discriminator, so the keys stored in the database remain derivable and previously encrypted values stay readable across deploys. + +**Why a constant rather than configuration** (Q1 = A): it cannot be set wrong, forgotten during a host migration, or accidentally differ between two instances that share a database. A configurable value would add a way to reintroduce the exact failure this unit exists to prevent. + +--- + +## 5. Migration Failure Classification + +Per Q3 = C, failures are classified rather than treated uniformly: + +| Failure kind | Meaning | Response | +|---|---|---| +| **Connection failure** | The database is not reachable yet — typically the app and SQL Server starting together after a host reboot | Retry with increasing delay, then fail | +| **Migration failure** | A migration is invalid, conflicts, or cannot be applied | Fail immediately, no retry | + +Retrying a genuine migration failure would only delay the inevitable while making the log harder to read. Failing instantly on a transient connection error would make a host reboot look like a broken deployment. + +**Interaction with U1's health check**: after retries are exhausted the exception propagates and the process does not start. `/health` then does not answer, and UptimeRobot goes red. That chain is the entire reason a liveness-only check is sufficient — it is meaningful precisely because startup is strict. + +--- + +## 6. Migration Ordering + +```mermaid +graph TD + corectx["ApplicationDbContext
Identity, refresh tokens,
invitations, Data Protection keys"] + availctx["AvailabilityDbContext
master registration"] + masterctx["MasterDbContext
CMS instances"] + protector["IDataProtector consumers
encrypted API keys"] + + corectx -->|"migrates first, at startup"| availctx + corectx -->|"migrates first, at startup"| masterctx + corectx -->|"keys table must exist before"| protector + availctx -->|"uses"| protector + masterctx -->|"uses"| protector + + classDef core fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef module fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef consumer fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + class corectx core; + class availctx,masterctx module; + class protector consumer; +``` + +Text alternative: the Core context migrates first because it now owns the Data Protection keys table, which both module contexts depend on indirectly through their encrypted API key handling. + +**Why Core must be first**: the keys table lives in `ApplicationDbContext` (Q7 of Application Design = A). Both modules encrypt and decrypt API keys. If a module migrated and immediately used an `IDataProtector` before the keys table existed, key generation would fail against a missing table. + +**Scope boundary**: U2 adds automatic migration for `ApplicationDbContext` only. `AvailabilityDbContext` and `MasterDbContext` already migrate themselves in their `UseModule` implementations, and that existing behaviour is left untouched — changing it would alter module behaviour beyond this feature's scope. + +--- + +## 7. Concurrent Migration + +Per Q4 = C, no locking mechanism is built. The design assumption is **one instance per database**, which holds today: the Master and each slave have their own database. + +This is documented in the deployment instructions as an operational constraint rather than enforced in code — building a distributed migration lock for a situation that cannot currently occur would add failure modes without removing any. + +**Recorded as an assumption**: if the deployment model ever changes to multiple instances sharing one database, automatic startup migration must be revisited before that change is made. diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-rules.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-rules.md new file mode 100644 index 0000000..ba86a52 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/business-rules.md @@ -0,0 +1,130 @@ +# Business Rules — U2 Data Durability + +--- + +## Migration Decision Logic + +```mermaid +graph TD + start["Startup: migrate ApplicationDbContext"] + attempt["Attempt migration"] + ok{"Succeeded ?"} + done["Continue startup"] + kind{"Failure kind ?"} + attempts{"Retry attempts remaining ?"} + wait["Wait with increasing delay"] + logfail["Log the failure with context"] + stop["Propagate: process does not start"] + + start --> attempt + attempt --> ok + ok -->|yes| done + ok -->|no| kind + kind -->|"connection failure"| attempts + kind -->|"migration failure"| logfail + attempts -->|yes| wait + attempts -->|no| logfail + wait --> attempt + logfail --> stop + + classDef entry 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 start entry; + class ok,kind,attempts decision; + class attempt,wait,done good; + class logfail,stop bad; +``` + +Text alternative: migration is retried with increasing delay only when the failure is a connection problem; a genuine migration failure is logged and stops the process immediately, as does exhausting the retry attempts. + +--- + +## Key Ring Durability Rules + +| ID | Rule | +|---|---| +| **BR-U2-01** | Data Protection keys are persisted in the database, in the context that owns Identity — never on the filesystem. | +| **BR-U2-02** | The application discriminator is a **fixed constant in code**. It is not derived from any path, and it is not configurable. | +| **BR-U2-03** | Data Protection is configured **exactly once**, by the host, before module service registration. | +| **BR-U2-04** | No module may call `AddDataProtection()`. Modules consume `IDataProtector` only. | +| **BR-U2-05** | Key lifetime uses the framework default of 90 days, with automatic rotation. | +| **BR-U2-06** | Old keys are **never** deleted. Removing a key makes every value encrypted with it permanently unreadable, including stored slave API keys. | +| **BR-U2-07** | Keys are stored unencrypted at rest in the database. This is an accepted, documented deviation — see DEV-05. | +| **BR-U2-08** | The database connection must enforce TLS, and the database must not be publicly reachable. These are the compensating controls for BR-U2-07. | + +**Rationale for BR-U2-02**: the default discriminator derives from the content root path, which changes on every atomic release switch. Persisting keys in the database while letting the discriminator move produces keys that are stored but underivable — the same failure, quieter. A constant cannot be forgotten during a host migration or accidentally differ between two instances sharing a database. + +**Rationale for BR-U2-03 and BR-U2-04**: this is the § 5.1 conflict. Because module registration runs after the host's, a module's bare `AddDataProtection()` would override the persistent store. `IDataProtector` still resolves, so registration tests pass — the defect appears only after the first release switch, as slave API keys that no longer decrypt. + +**Rationale for BR-U2-06**: this is the single most destructive maintenance action available against this system. Pruning the keys table looks like harmless housekeeping and permanently breaks every Master↔slave relationship. It must be stated in the deployment documentation, not only in code comments (Q5 = C). + +--- + +## Migration Rules + +| ID | Rule | +|---|---| +| **BR-U2-09** | `ApplicationDbContext` migrations are applied automatically at startup, before the application accepts traffic. | +| **BR-U2-10** | Core migrations run **before** module middleware installation, so the keys table exists before any module resolves an `IDataProtector`. | +| **BR-U2-11** | A **connection** failure is retried with increasing delay, up to a bounded number of attempts. | +| **BR-U2-12** | A **migration** failure — invalid, conflicting, or inapplicable — fails immediately with no retry. | +| **BR-U2-13** | When retries are exhausted, or on a migration failure, the exception propagates and the process does not start. | +| **BR-U2-14** | Before failing, the reason is logged with enough context to diagnose it — but never including the connection string, credentials, or any secret. | +| **BR-U2-15** | `AvailabilityDbContext` and `MasterDbContext` keep their existing self-migration in `UseModule`. U2 does not change them. | +| **BR-U2-16** | Migrations must be forward-compatible and non-destructive, so redeploying an earlier release remains a valid rollback. | + +**Rationale for BR-U2-11 and BR-U2-12**: the two failures mean different things. On the Pi the application and SQL Server may start together after a reboot, so a brief unavailability window is normal operation, not a fault. A broken migration is a fault, and retrying it only delays the inevitable while filling the log. + +**Rationale for BR-U2-13**: this is what gives U1's liveness check meaning. A process that starts regardless would report healthy while being unusable; strict startup makes the absence of a `/health` response a trustworthy signal. + +**Rationale for BR-U2-16**: rollback is "redeploy the previous release" (D-26). That is only safe if the older code can run against the newer schema. A destructive migration — dropping a column, narrowing a type — makes rollback impossible precisely when it is most needed. + +--- + +## Operational Constraint Rules + +| ID | Rule | +|---|---| +| **BR-U2-17** | Exactly one application instance may migrate a given database at a time. Not enforced in code; documented as an operational constraint (Q4 = C). | +| **BR-U2-18** | Each instance has its own database. The Master and each slave never share one. | + +**Rationale**: `Database.Migrate()` is not safe under concurrency. Today the constraint holds by design — one instance per database — so a distributed lock would add failure modes without removing any. Should the deployment model ever change to multiple instances sharing a database, automatic startup migration must be revisited **before** that change is made. + +--- + +## Error and Edge-Case Scenarios + +| Scenario | Expected behaviour | +|---|---| +| Fresh database, no tables | All Core migrations applied, including the keys table; startup proceeds | +| Database up to date | Migration is a no-op; startup proceeds | +| Database unreachable at startup, comes up within the retry window | Retries succeed; startup proceeds; the delay is logged | +| Database unreachable for the whole retry window | Logged, process does not start, `/health` silent, UptimeRobot red | +| Migration conflicts with existing schema | Failed immediately, no retry, process does not start | +| Credentials wrong | Treated as a connection failure — retried, then fails. Distinguishing bad credentials from an unreachable server is not worth the complexity; the outcome is identical | +| Keys table empty on first run | Data Protection generates a key and persists it; normal first-run behaviour | +| Keys table populated from a previous release | Existing keys are read; previously encrypted values remain readable — the purpose of the unit | +| Release directory changed since last start | Irrelevant — the discriminator is a constant (BR-U2-02) | +| Someone deletes rows from the keys table | Every value encrypted with those keys becomes permanently unreadable. Prevented by documentation only (BR-U2-06) | +| Two instances start simultaneously against one database | Undefined. Prevented by the operational constraint (BR-U2-17), not by code | +| A module still calls `AddDataProtection()` after this unit | The persistent store is silently overridden. Prevented by BR-U2-04 and asserted by a test | + +--- + +## Security Compliance for U2 + +| Rule | Status | Notes | +|---|---|---| +| SECURITY-01 | **Partially compliant — DEV-05** | Encryption **in transit** enforced by BR-U2-08 (TLS on the connection). Encryption **at rest** for the keys themselves is deferred: keys are stored unencrypted, relying on the database's own at-rest encryption and network isolation. Accepted with a follow-up (Q2 = C) | +| SECURITY-03 | Compliant | BR-U2-14 requires diagnostic context without secrets | +| SECURITY-09 | Compliant | No default credentials; failure messages carry no connection details | +| SECURITY-13 | **Improved** | The key ring surviving redeploys is precisely a software-integrity property: without it, the encrypted API keys that authenticate Master↔slave communication silently become invalid | +| SECURITY-15 | Compliant | Fails closed — the process does not start rather than serving in an unknown schema state | + +### New Documented Deviation + +| ID | Deviation | Rationale | Decided | +|---|---|---|---| +| **DEV-05** | **Data Protection keys are stored unencrypted at rest.** SECURITY-01 requires encryption at rest for persisted data. | DPAPI is unavailable on Linux, and X.509 certificate encryption relocates the loss problem to the certificate — reintroducing the failure mode this unit exists to eliminate. Compensating controls: TLS on the database connection, and the database not publicly reachable (BR-U2-08). Certificate-based encryption is recorded as a separate follow-up item. | Q2 = C | diff --git a/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/domain-entities.md b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/domain-entities.md new file mode 100644 index 0000000..ac0044d --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/construction/u2-data-durability/functional-design/domain-entities.md @@ -0,0 +1,124 @@ +# Domain Entities — U2 Data Durability + +U2 adds **one** persisted entity and **one** migration. Everything else in the unit is configuration and startup behaviour. + +--- + +## Entity Relationships + +```mermaid +graph TD + appctx["ApplicationDbContext
implements IDataProtectionKeyContext"] + key["DataProtectionKey
NEW"] + user["ApplicationUser
existing"] + refresh["RefreshToken
existing"] + invite["Invitation
existing"] + avail["GlobalAvailabilityState
existing"] + protector["IDataProtector
derived from keys"] + cmsinst["CmsInstance
MasterDbContext"] + mastreg["MasterRegistration
AvailabilityDbContext"] + + appctx -->|"owns"| key + appctx -->|"owns"| user + appctx -->|"owns"| refresh + appctx -->|"owns"| invite + appctx -->|"owns"| avail + key -->|"derives"| protector + protector -->|"encrypts API key of"| cmsinst + protector -->|"encrypts API key of"| mastreg + + classDef ctx fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000; + classDef newent fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000; + classDef existing fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000; + classDef derived fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000; + classDef consumer fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000; + class appctx ctx; + class key newent; + class user,refresh,invite,avail existing; + class protector derived; + class cmsinst,mastreg consumer; +``` + +Text alternative: the Core context gains a Data Protection keys table alongside its existing Identity entities; those keys derive the protector that encrypts the API keys stored on CMS instances and master registrations in the two module contexts. + +**Cross-context dependency worth noting**: the keys live in `ApplicationDbContext`, while the values they protect live in `MasterDbContext` and `AvailabilityDbContext`. There is no foreign key between them — all three contexts share one database, but the relationship is behavioural, not relational. Losing the keys does not produce a referential-integrity error; it produces rows whose encrypted column can no longer be read. That is exactly why the failure is silent. + +--- + +## DataProtectionKey (new) + +Provided by the framework via `IDataProtectionKeyContext`; the schema is not authored by this project. + +| Field | Type | Purpose | +|---|---|---| +| `Id` | int, identity | Primary key | +| `FriendlyName` | string, nullable | Human-readable key identifier | +| `Xml` | string | The serialized key material | + +### Constraints and rules + +| Aspect | Rule | +|---|---| +| Owning context | `ApplicationDbContext` (Q7 of Application Design = A) | +| Migration | One new Core migration, applied automatically at startup by FR-11 | +| Encryption at rest | **None** — see DEV-05. `Xml` contains usable key material in plain text | +| Retention | Rows are **never** deleted (BR-U2-06) | +| Rotation | Framework default, 90 days; new rows are added, old rows retained | +| Access | Only through the Data Protection API. No application code reads or writes this table directly | + +**Why `Xml` being plaintext matters**: anyone who can read this table can decrypt every stored slave API key. This is the substance of DEV-05, and why BR-U2-08 requires TLS on the connection and a database that is not publicly reachable. Those compensating controls are not optional extras — they are what makes the deviation acceptable. + +--- + +## ApplicationDbContext (modified) + +| Change | Detail | +|---|---| +| Interface | Implements `IDataProtectionKeyContext` | +| New set | `DbSet DataProtectionKeys` | +| Existing sets | Unchanged — Identity, `RefreshToken`, `Invitation`, `ModulePermission`, `GlobalAvailabilityState` | +| Migration behaviour | **Changed**: now migrates automatically at startup (FR-11). Previously required a manual `dotnet ef database update` | + +**Note on the behaviour change**: automatic migration is a genuine change in operational semantics, not merely a convenience. Previously a schema change reached production only when a human ran a command; now it happens whenever a new release starts. This is why forward-compatible, non-destructive migrations (BR-U2-16) and a pre-deploy backup (FR-20) are load-bearing rather than nice to have. + +--- + +## Configuration Values + +U2 introduces **no new `appsettings` section**. + +| Value | Source | Rationale | +|---|---|---| +| Application discriminator | **Constant in code** | Q1 = A. Cannot be misconfigured, forgotten, or made to differ between instances sharing a database | +| Key lifetime | Framework default (90 days) | Q5 = C. No reason to differ | +| Migration retry attempts and delays | Constants in code | Values chosen to cover a host-reboot window; not an operational tuning knob | +| Connection string | Existing `ConnectionStrings:DefaultConnection` | Unchanged. BR-U2-08 requires TLS to be enforced in it | + +**Why nothing is configurable here**: every value in this unit exists to prevent a silent failure. A configuration surface would be a way to reintroduce that failure — a discriminator set wrong on one instance, or a key lifetime set so short that rotation outpaces retention. + +--- + +## Persistence Summary + +| Question | Answer | +|---|---| +| New tables? | One — the Data Protection keys table | +| New migrations? | One, in `SlpModularCms.Core` | +| Modified entities? | None. `ApplicationDbContext` gains a set but no existing entity changes | +| Destructive schema changes? | None. Purely additive, so rollback by redeploying an earlier release stays safe | +| New configuration? | None | + +--- + +## Verification Targets + +What this unit's tests must actually prove, given that the failure mode is silent: + +| Target | Why it needs asserting | +|---|---| +| The persistent key store survives module registration | Registration alone passes in both the broken and fixed cases — only the resulting configuration distinguishes them | +| The application discriminator is the fixed constant | The default would change per release directory, defeating persistence | +| A protected value round-trips across a simulated content-root change | This is the actual user-visible property: an API key encrypted before a deploy is still readable after it | +| Neither module registers Data Protection | Prevents the conflict from being reintroduced by a future change to either module | +| A connection failure retries; a migration failure does not | The two paths differ deliberately (BR-U2-11, BR-U2-12) | +| Both hosts still start | `SlpModularCms.Api.Slave` has no test project and is a reference instance (Q2 of Application Design = A) | diff --git a/src/SlpModularCms.Api.Slave/Program.cs b/src/SlpModularCms.Api.Slave/Program.cs index ba5fa60..9b65f9c 100644 --- a/src/SlpModularCms.Api.Slave/Program.cs +++ b/src/SlpModularCms.Api.Slave/Program.cs @@ -1,4 +1,5 @@ using SlpModularCms.Core.Hosting; +using SlpModularCms.Core.Hosting.Health; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +16,10 @@ orchestrator.DiscoverModules(); builder.Services.AddCoreInfrastructure(builder.Configuration); builder.Services.AddCmsCors(builder.Configuration); builder.Services.AddCmsRateLimiting(builder.Configuration); +builder.Services.AddCmsHealthChecks(); + +// Registered BEFORE module services — see the note in DataProtectionExtensions. +builder.Services.AddCmsDataProtection(); // 3. Add Module Services orchestrator.RegisterModuleServices(builder.Services); @@ -32,6 +37,9 @@ builder.Services.AddControllers(options => var app = builder.Build(); +// Same as the master host: Core schema first, fail fast on failure. +app.MigrateCoreDatabase(); + // 5. Global Exception Handling app.UseExceptionHandler(); @@ -56,4 +64,9 @@ app.UseAuthorization(); app.MapControllers(); +// Infrastructure liveness, same as the master host. This instance serves no static content, +// so it gets no website or admin mounts — but it is a reference for what a customer-facing +// API instance looks like, so it behaves like one in every other respect. +app.MapCmsHealthChecks(); + app.Run(); diff --git a/src/SlpModularCms.Api/Program.cs b/src/SlpModularCms.Api/Program.cs index 582ff69..9002f43 100644 --- a/src/SlpModularCms.Api/Program.cs +++ b/src/SlpModularCms.Api/Program.cs @@ -1,4 +1,6 @@ +using SlpModularCms.Api.Extensions; using SlpModularCms.Core.Hosting; +using SlpModularCms.Core.Hosting.Health; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +17,12 @@ orchestrator.DiscoverModules(); builder.Services.AddCoreInfrastructure(builder.Configuration); builder.Services.AddCmsCors(builder.Configuration); builder.Services.AddCmsRateLimiting(builder.Configuration); +builder.Services.AddCmsHealthChecks(); + +// Registered BEFORE module services: modules must not configure Data Protection themselves, +// because a later registration would override this persistent key store (see +// DataProtectionExtensions). +builder.Services.AddCmsDataProtection(); // 3. Add Module Services orchestrator.RegisterModuleServices(builder.Services); @@ -32,6 +40,13 @@ builder.Services.AddControllers(options => var app = builder.Build(); +// Bring the Core schema up to date before serving any traffic. Runs before the module +// middleware below, because the Data Protection keys table lives in this context and the +// modules resolve an IDataProtector as soon as they start. Fails fast: a host that cannot +// migrate does not start, so /health goes silent and monitoring goes red — which is exactly +// what makes a liveness-only health check trustworthy. +app.MigrateCoreDatabase(); + // 5. Global Exception Handling app.UseExceptionHandler(); @@ -47,10 +62,11 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); // Serve the public website ('/') and the CMS admin SPA ('/admin') from wwwroot. -// wwwroot/index.html + assets -> public website (built and deployed separately, not part of this repo) +// wwwroot/web/index.html + assets -> public website (built and deployed separately, not part of this repo) // wwwroot/admin/index.html + assets -> CMS admin build (see frontend/, copied in on publish) -app.UseDefaultFiles(); -app.UseStaticFiles(); +// Registered before the module middleware below: static files short-circuit the pipeline, so +// anything that must observe them has to come first. +app.UseCmsStaticContent(); app.UseCors(); @@ -62,9 +78,14 @@ app.UseAuthorization(); app.MapControllers(); +// Infrastructure liveness. Deliberately outside /api/v1 and on the availability gate's bypass +// list: this reports whether the process is alive, which is a different question from whether +// the CMS is switched on (/api/v1/Availability/status) or which modules it carries +// (/api/v1/System/capabilities). Those are CMS domain state and must not be used for monitoring. +app.MapCmsHealthChecks(); + // SPA fallbacks so client-side routes (e.g. /admin/dashboard) resolve to the right index.html // instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s. -app.MapFallbackToFile("/admin/{*path:nonfile}", "admin/index.html"); -app.MapFallbackToFile("{*path:nonfile}", "index.html"); +app.MapCmsSpaFallbacks(); app.Run(); diff --git a/src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs b/src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs new file mode 100644 index 0000000..ca23759 --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/DataProtectionExtensionsTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlpModularCms.Core.Data; +using SlpModularCms.Core.Hosting; +using Xunit; + +namespace SlpModularCms.Core.Tests.Hosting; + +/// +/// Guards the durability of the Data Protection key ring. +/// +/// +/// The failure this protects against is silent. Losing the key ring produces no error — it +/// produces stored slave API keys that no longer decrypt, which looks like a network fault +/// between a Master and its slaves. A test that merely asserted "Data Protection is registered" +/// would pass in the broken case too, so these tests assert the resulting configuration instead. +/// +public class DataProtectionExtensionsTests +{ + [Fact] + public void AddCmsDataProtection_ShouldPersistKeysToTheDatabase() + { + using var provider = BuildProvider(nameof(AddCmsDataProtection_ShouldPersistKeysToTheDatabase)); + + var options = provider.GetRequiredService>().Value; + + // The default is a filesystem key ring, which every atomic release switch discards. + options.XmlRepository.Should().BeOfType>(); + } + + [Fact] + public void AddCmsDataProtection_ShouldUseAFixedApplicationDiscriminator() + { + using var provider = BuildProvider(nameof(AddCmsDataProtection_ShouldUseAFixedApplicationDiscriminator)); + + var options = provider.GetRequiredService>().Value; + + // The default derives from the content root path, which changes with every release + // directory — so keys stored in the database would still stop being derivable. + options.ApplicationDiscriminator.Should().Be(DataProtectionExtensions.ApplicationDiscriminator); + } + + [Fact] + public void ProtectedValues_ShouldSurviveAProcessRestartFromADifferentDirectory() + { + // The property that actually matters: an API key encrypted before a deploy must still be + // readable by the process that starts afterwards from a different release directory. + // Both providers share one database and one fixed discriminator, which is what makes + // that possible. + const string databaseName = nameof(ProtectedValues_ShouldSurviveAProcessRestartFromADifferentDirectory); + const string secret = "slave-api-key-value"; + + string encrypted; + using (var beforeDeploy = BuildProvider(databaseName)) + { + encrypted = beforeDeploy + .GetRequiredService() + .CreateProtector("MasterApiKey") + .Protect(secret); + } + + using var afterDeploy = BuildProvider(databaseName); + + var decrypted = afterDeploy + .GetRequiredService() + .CreateProtector("MasterApiKey") + .Unprotect(encrypted); + + decrypted.Should().Be(secret); + } + + [Fact] + public void ApplicationDiscriminator_ShouldNotBeDerivedFromAPath() + { + // Guards against someone "improving" this into a configurable or computed value: every + // knob here is a way to reintroduce the silent failure the key ring exists to prevent. + DataProtectionExtensions.ApplicationDiscriminator.Should().Be("SlpModularCms"); + DataProtectionExtensions.ApplicationDiscriminator.Should().NotContainAny("/", "\\", ":"); + } + + private static ServiceProvider BuildProvider(string databaseName) + { + var services = new ServiceCollection(); + + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + services.AddDbContext(options => options.UseInMemoryDatabase(databaseName)); + services.AddCmsDataProtection(); + + return services.BuildServiceProvider(); + } +} diff --git a/src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs b/src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs new file mode 100644 index 0000000..28e452e --- /dev/null +++ b/src/SlpModularCms.Core.Tests/Hosting/DatabaseMigrationExtensionsTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using Microsoft.Data.SqlClient; +using SlpModularCms.Core.Hosting; +using Xunit; + +namespace SlpModularCms.Core.Tests.Hosting; + +/// +/// Guards the failure classification of the startup migration. +/// +/// +/// Startup migration distinguishes two failures that mean very different things: +/// a database that is not up yet (normal when the application and the database server start +/// together after a host reboot) versus a migration that is broken. Retrying the first is +/// correct; retrying the second only delays the inevitable and fills the log. +/// +/// MigrateCoreDatabase itself needs a composed WebApplication, so the classifier is +/// exercised directly here; the composed startup path is verified at the phase-level Build and +/// Test stage by starting both hosts. +/// +public class DatabaseMigrationExtensionsTests +{ + [Fact] + public void MaxConnectionAttempts_ShouldAllowForAHostRebootWindow() + { + // Enough attempts that a database server starting alongside the application is tolerated, + // few enough that a genuinely unreachable database still fails promptly. + DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeGreaterThan(1); + DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeLessThanOrEqualTo(10); + } + + [Theory] + [MemberData(nameof(TransientFailures))] + public void IsTransientConnectionFailure_ShouldRecogniseConnectionProblems(Exception exception) + { + InvokeClassifier(exception).Should().BeTrue(); + } + + [Theory] + [MemberData(nameof(NonTransientFailures))] + public void IsTransientConnectionFailure_ShouldNotRecogniseMigrationProblems(Exception exception) + { + // A broken migration must fail immediately. Classifying it as transient would hide a + // real fault behind a retry loop. + InvokeClassifier(exception).Should().BeFalse(); + } + + public static TheoryData TransientFailures() => + [ + MakeSqlException(), + new TimeoutException("Connect Timeout expired."), + new InvalidOperationException("wrapper", MakeSqlException()), + ]; + + public static TheoryData NonTransientFailures() => + [ + new InvalidOperationException("There is already an object named 'Users' in the database."), + new NotSupportedException("The migration cannot be applied."), + new AggregateException(new InvalidOperationException("pending model changes")), + ]; + + private static bool InvokeClassifier(Exception exception) + { + var method = typeof(DatabaseMigrationExtensions).GetMethod( + "IsTransientConnectionFailure", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + method.Should().NotBeNull("the failure classifier is the behaviour under test"); + + return (bool)method!.Invoke(null, [exception])!; + } + + /// + /// has no public constructor, so one is produced through the + /// framework's own factory path via reflection. + /// + private static Exception MakeSqlException() + { + try + { + // Deliberately unreachable host and a very short timeout: this genuinely produces a + // SqlException rather than a hand-built stand-in, so the classifier is tested against + // the real type it will encounter in production. + using var connection = new SqlConnection( + "Server=localhost,9;Database=none;User Id=sa;Password=none;Connect Timeout=1;TrustServerCertificate=True"); + connection.Open(); + } + catch (Exception ex) + { + return ex; + } + + throw new InvalidOperationException("Expected the connection attempt to fail."); + } +} diff --git a/src/SlpModularCms.Core/Data/ApplicationDbContext.cs b/src/SlpModularCms.Core/Data/ApplicationDbContext.cs index 9cdf9c8..fa867e6 100644 --- a/src/SlpModularCms.Core/Data/ApplicationDbContext.cs +++ b/src/SlpModularCms.Core/Data/ApplicationDbContext.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -8,7 +9,12 @@ namespace SlpModularCms.Core.Data; /// /// Database context voor de applicatie, inclusief Identity en RBAC tabellen. /// -public class ApplicationDbContext : IdentityDbContext +/// +/// Also hosts the ASP.NET Core Data Protection key ring. The keys are application-wide +/// infrastructure rather than module-owned data, and this context is the one that migrates +/// automatically at startup — so the table exists without any manual step on the host. +/// +public class ApplicationDbContext : IdentityDbContext, IDataProtectionKeyContext { public ApplicationDbContext(DbContextOptions options) : base(options) @@ -20,6 +26,13 @@ public class ApplicationDbContext : IdentityDbContext ModulePermissions => Set(); public DbSet AvailabilityStates => Set(); + /// + /// Data Protection key ring. Rows here MUST NEVER be pruned: deleting a key makes every + /// value ever encrypted with it permanently unreadable, including the stored API keys that + /// authenticate master/slave communication. + /// + public DbSet DataProtectionKeys => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs b/src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs new file mode 100644 index 0000000..20875be --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/DataProtectionExtensions.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.DependencyInjection; +using SlpModularCms.Core.Data; + +namespace SlpModularCms.Core.Hosting; + +/// +/// Configures ASP.NET Core Data Protection so encrypted values survive a redeploy. +/// +/// +/// Data Protection secures the API keys that authenticate master/slave communication. Losing the +/// key ring does not produce an error — it produces stored keys that no longer decrypt, which +/// presents as an apparent network fault between a Master and its slaves and is easily +/// misdiagnosed. Two separate defaults would each cause exactly that: +/// +/// 1. Keys are held on the filesystem by default. Deployments swap the release directory +/// atomically, so a filesystem key ring is discarded on every deploy. +/// 2. The application discriminator is derived from the content root path by default. That path +/// changes with every release directory, so even keys stored in the database would stop being +/// derivable. +/// +/// This method closes both. It MUST be called before module service registration — see the +/// remarks on . +/// +public static class DataProtectionExtensions +{ + /// + /// Stable identity of this application for key derivation. + /// + /// + /// A constant rather than a configuration value, deliberately. Every configurable knob here + /// is a way to reintroduce the silent failure this whole mechanism exists to prevent — a + /// discriminator set differently on one instance, or forgotten during a host migration, + /// makes previously encrypted values unreadable with no error to point at. + /// + public const string ApplicationDiscriminator = "SlpModularCms"; + + /// + /// Registers Data Protection with a database-backed key ring and a fixed application + /// discriminator. + /// + /// + /// MUST be called before ModuleOrchestrator.RegisterModuleServices. Modules must not + /// call AddDataProtection() themselves: module registration runs after the host's, and + /// a later bare call re-registers the configuration chain, silently discarding the persistent + /// key store configured here. IDataProtector resolves either way, so such a regression + /// passes registration tests and only surfaces after the first release switch. + /// + public static IServiceCollection AddCmsDataProtection(this IServiceCollection services) + { + services + .AddDataProtection() + .SetApplicationName(ApplicationDiscriminator) + .PersistKeysToDbContext(); + + return services; + } +} diff --git a/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs b/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs new file mode 100644 index 0000000..9b4ec87 --- /dev/null +++ b/src/SlpModularCms.Core/Hosting/DatabaseMigrationExtensions.cs @@ -0,0 +1,112 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SlpModularCms.Core.Data; + +namespace SlpModularCms.Core.Hosting; + +/// +/// Applies the Core database migrations at startup. +/// +/// +/// Deployment targets are shared hosts where no CLI is available, so migrations cannot be a +/// manual step on the server. Applying them at startup makes a deployment self-contained. +/// +/// The cost of that convenience is that a migration now runs without a human gate — which is why +/// migrations must stay forward-compatible and non-destructive (rollback is "redeploy the previous +/// release", and that only works if older code can run against the newer schema), and why a +/// database backup precedes every production deploy. +/// +public static class DatabaseMigrationExtensions +{ + /// Attempts made when the database is not reachable yet. + public const int MaxConnectionAttempts = 5; + + private static readonly TimeSpan BaseRetryDelay = TimeSpan.FromSeconds(2); + + /// + /// Applies pending migrations before the application + /// serves traffic. + /// + /// + /// Failures are classified rather than treated alike: + /// + /// A connection failure means the database is not up yet — normal + /// when the application and the database server start together after a host reboot. Retried + /// with increasing delay. + /// A migration failure means a migration is invalid or conflicts. + /// Retrying only delays the inevitable and fills the log, so it fails at once. + /// + /// Either way the exception ultimately propagates and the process does not start. That is + /// deliberate and is what makes the liveness health check meaningful: an application that + /// cannot reach its schema never answers /health, so monitoring goes red instead of + /// reporting a healthy instance that cannot serve a single request. + /// + public static WebApplication MigrateCoreDatabase(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + var logger = app.Services.GetRequiredService() + .CreateLogger(typeof(DatabaseMigrationExtensions).FullName!); + + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + for (var attempt = 1; ; attempt++) + { + try + { + context.Database.Migrate(); + logger.LogInformation("Core database migrations applied successfully."); + return app; + } + catch (Exception ex) when (IsTransientConnectionFailure(ex) && attempt < MaxConnectionAttempts) + { + var delay = BaseRetryDelay * attempt; + + logger.LogWarning( + "Database not reachable on attempt {Attempt} of {MaxAttempts}. Retrying in {DelaySeconds}s. Reason: {Reason}", + attempt, + MaxConnectionAttempts, + delay.TotalSeconds, + ex.Message); + + Thread.Sleep(delay); + } + catch (Exception ex) + { + // Logged with the failure reason but never the connection string or credentials — + // this message travels to the console and to Sentry. + logger.LogCritical( + ex, + "Core database migration failed after {Attempts} attempt(s). The application will not start.", + attempt); + + throw; + } + } + } + + /// + /// Distinguishes "the database is not there yet" from "the migration is broken". + /// + /// + /// Wrong credentials are treated as a connection failure too. Telling them apart from an + /// unreachable server would add branching for no benefit: the outcome is identical — retries + /// are exhausted and the process does not start. + /// + private static bool IsTransientConnectionFailure(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is SqlException or TimeoutException) + { + return true; + } + } + + return false; + } +} diff --git a/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.Designer.cs b/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.Designer.cs new file mode 100644 index 0000000..0d7a048 --- /dev/null +++ b/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.Designer.cs @@ -0,0 +1,452 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SlpModularCms.Core.Data; + +#nullable disable + +namespace SlpModularCms.Core.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260727203036_AddDataProtectionKeys")] + partial class AddDataProtectionKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.GlobalAvailabilityState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("AvailabilityState", (string)null); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiryDate") + .HasColumnType("datetimeoffset"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("Role") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ModuleName") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Permission") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("UserId", "ModuleName", "Permission"); + + b.ToTable("ModulePermissions"); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByIp") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiryDate") + .HasColumnType("datetimeoffset"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User") + .WithMany("ModulePermissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b => + { + b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b => + { + b.Navigation("ModulePermissions"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs b/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs new file mode 100644 index 0000000..89442e7 --- /dev/null +++ b/src/SlpModularCms.Core/Migrations/20260727203036_AddDataProtectionKeys.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SlpModularCms.Core.Migrations +{ + /// + public partial class AddDataProtectionKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FriendlyName = table.Column(type: "nvarchar(max)", nullable: true), + Xml = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + } + } +} diff --git a/src/SlpModularCms.Core/Migrations/ApplicationDbContextModelSnapshot.cs b/src/SlpModularCms.Core/Migrations/ApplicationDbContextModelSnapshot.cs index e64daf1..6fb2f3b 100644 --- a/src/SlpModularCms.Core/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/SlpModularCms.Core/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,6 +1,5 @@ // using System; -using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -12,7 +11,6 @@ using SlpModularCms.Core.Data; namespace SlpModularCms.Core.Migrations { [DbContext(typeof(ApplicationDbContext))] - [ExcludeFromCodeCoverage] partial class ApplicationDbContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) @@ -24,6 +22,25 @@ namespace SlpModularCms.Core.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") diff --git a/src/SlpModularCms.Core/SlpModularCms.Core.csproj b/src/SlpModularCms.Core/SlpModularCms.Core.csproj index d18b061..e3b0653 100644 --- a/src/SlpModularCms.Core/SlpModularCms.Core.csproj +++ b/src/SlpModularCms.Core/SlpModularCms.Core.csproj @@ -13,6 +13,13 @@ + + diff --git a/src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs new file mode 100644 index 0000000..98abc56 --- /dev/null +++ b/src/SlpModularCms.Modules.Availability.Tests/AvailabilityModuleDataProtectionTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlpModularCms.Core.Data; +using SlpModularCms.Core.Hosting; +using Xunit; + +namespace SlpModularCms.Modules.Availability.Tests; + +/// +/// Proves this module does not undo the host's Data Protection configuration. +/// +/// +/// This module used to call services.AddDataProtection() itself. Module registration runs +/// AFTER the host's, so that bare call re-registered the configuration chain and silently +/// discarded the host's database-backed key store — leaving the key ring on the filesystem, where +/// every deployment discards it. +/// +/// The defect was invisible: IDataProtector still resolved, so any test asserting that +/// Data Protection "is registered" passed. It only surfaced after a release switch, as stored +/// slave API keys that no longer decrypted — presenting as a network fault between Master and +/// slave. This test asserts the resulting configuration, which is the only thing that +/// distinguishes the two cases. +/// +public class AvailabilityModuleDataProtectionTests +{ + [Fact] + public void RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore() + { + var services = new ServiceCollection(); + + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddDbContext(options => + options.UseInMemoryDatabase(nameof(RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore))); + + // Host first, module second — the real ordering. + services.AddCmsDataProtection(); + new AvailabilityModule().RegisterServices(services); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + options.XmlRepository.Should().BeOfType>( + "the module must not reconfigure Data Protection — the host owns it"); + } +} diff --git a/src/SlpModularCms.Modules.Availability/AvailabilityModule.cs b/src/SlpModularCms.Modules.Availability/AvailabilityModule.cs index 5ac9b16..9c8dd72 100644 --- a/src/SlpModularCms.Modules.Availability/AvailabilityModule.cs +++ b/src/SlpModularCms.Modules.Availability/AvailabilityModule.cs @@ -31,7 +31,12 @@ public class AvailabilityModule : IModule options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")); }); - services.AddDataProtection(); + // Data Protection is configured once by the host (AddCmsDataProtection), NOT here. + // Module registration runs after the host's, so a bare AddDataProtection() call at this + // point would re-register the configuration chain and silently discard the persistent + // database-backed key store — leaving the key ring on the filesystem, where every + // deployment discards it. IDataProtector resolves either way, so the regression would + // pass its tests and only surface later as slave API keys that no longer decrypt. services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs b/src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs new file mode 100644 index 0000000..cee71a7 --- /dev/null +++ b/src/SlpModularCms.Modules.Master.Tests/MasterModuleDataProtectionTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlpModularCms.Core.Data; +using SlpModularCms.Core.Hosting; +using Xunit; + +namespace SlpModularCms.Modules.Master.Tests; + +/// +/// Proves this module does not undo the host's Data Protection configuration. +/// +/// +/// See the equivalent test in the Availability module. This module encrypts the API keys of every +/// registered CMS instance, so if the key ring were silently returned to the filesystem, a single +/// deployment would make every registered slave unreachable — with no error to point at. +/// +public class MasterModuleDataProtectionTests +{ + [Fact] + public void RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore() + { + var services = new ServiceCollection(); + + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddDbContext(options => + options.UseInMemoryDatabase(nameof(RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore))); + + // Host first, module second — the real ordering. + services.AddCmsDataProtection(); + new MasterModule().RegisterServices(services); + + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + options.XmlRepository.Should().BeOfType>( + "the module must not reconfigure Data Protection — the host owns it"); + } +} diff --git a/src/SlpModularCms.Modules.Master/MasterModule.cs b/src/SlpModularCms.Modules.Master/MasterModule.cs index 6789444..70b0d75 100644 --- a/src/SlpModularCms.Modules.Master/MasterModule.cs +++ b/src/SlpModularCms.Modules.Master/MasterModule.cs @@ -22,7 +22,10 @@ public class MasterModule : IModule public void RegisterServices(IServiceCollection services) { - services.AddDataProtection(); + // Data Protection is configured once by the host (AddCmsDataProtection), NOT here. + // See the equivalent note in AvailabilityModule: a bare AddDataProtection() call here + // would override the host's persistent key store, and the resulting defect is invisible + // until the first release switch makes every stored slave API key undecryptable. services.AddSingleton(); services.AddOptions().BindConfiguration("MasterModule"); -- 2.39.5 From 357d3956296d2d642cc25f39a9a89955d4b4b325 Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Tue, 28 Jul 2026 00:01:04 +0200 Subject: [PATCH 04/35] Designs the security headers and observability units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the functional design for the two remaining application units, before any of their code exists. Security headers have to come from the application, because relying on nginx or IIS configuration is exactly what this deployment model rules out. Strict applies to /admin, /api/v1 and /health; a relaxed policy applies to the public website, which this repository does not author. The strict policy needs style-src 'unsafe-inline'. That is not a shortcut: Radix positions dropdowns and dialogs with inline style attributes recalculated per click and scroll position, and CSP nonces apply only to style elements, never to style attributes. No nonce- or hash-based variant leaves the admin UI working. The exception is bounded to styles — script-src stays closed, which is where XSS actually lives. The website's policy is enforcing rather than absent, so every HTML-serving path carries a CSP and no exception has to be recorded. It still blocks external script origins, so it remains a real boundary. HSTS is skipped in development: browsers remember it per host and localhost is shared with unrelated projects. Every other header applies locally, so a CSP violation surfaces while developing. For observability, browser error reports tunnel through the API rather than going to Sentry directly. Ad blockers block Sentry domains, which loses errors precisely for the users most likely to have browser oddities. The tunnel forwards only to the host derived from the configured DSN — a caller-supplied destination would turn an anonymous endpoint into a request-forgery primitive. Two consequences of the chosen options are recorded rather than left implicit: Enabling SendDefaultPii attaches request headers, and this application carries two standing credentials in them. Besides the refreshToken cookie, X-Master-Api-Key would have been sent to a third party on every error raised during a master/slave call. The scrub list removes the whole Cookie header, Authorization, X-Master-Api-Key and the request body. Console logging at Information plus structured logging to Sentry would, taken literally, mean one Sentry event per request — exhausting the free plan within hours and burying real errors in request noise. The thresholds are split: console keeps Information, Sentry takes warnings and above as events with Information as breadcrumbs, so every event arrives carrying the trail that led to it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw --- .../gitea-deployment-workflow/aidlc-state.md | 97 +++ .../gitea-deployment-workflow/audit.md | 802 ++++++++++++++++++ .../plans/u3-security-headers-fd-questions.md | 122 +++ ...security-headers-functional-design-plan.md | 45 + .../plans/u4-observability-fd-questions.md | 83 ++ ...u4-observability-functional-design-plan.md | 54 ++ .../functional-design/business-logic-model.md | 226 +++++ .../functional-design/business-rules.md | 137 +++ .../functional-design/domain-entities.md | 152 ++++ .../functional-design/business-logic-model.md | 237 ++++++ .../functional-design/business-rules.md | 151 ++++ .../functional-design/domain-entities.md | 153 ++++ .../functional-design/frontend-components.md | 212 +++++ 13 files changed, 2471 insertions(+) create mode 100644 aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/audit.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u3-security-headers-fd-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u3-security-headers-functional-design-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u4-observability-fd-questions.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/plans/u4-observability-functional-design-plan.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/functional-design/business-logic-model.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/functional-design/business-rules.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u3-security-headers/functional-design/domain-entities.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/business-logic-model.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/business-rules.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/domain-entities.md create mode 100644 aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/frontend-components.md diff --git a/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md new file mode 100644 index 0000000..68aff9f --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/aidlc-state.md @@ -0,0 +1,97 @@ +# AI-DLC State Tracking + +## Project Information +- **Feature Name**: Gitea Deployment Workflow +- **Feature Slug**: gitea-deployment-workflow +- **Project Type**: Brownfield +- **Start Date**: 2026-07-27T00:00:00Z +- **Current Stage**: CONSTRUCTION - Code Generation, Round 1 (U1 + U2 complete, awaiting approval) +- **Branch**: feature/gitea-deployment-workflow + +## Workspace State +- **Existing Code**: Yes +- **Reverse Engineering Needed**: Completed — full rerun on 2026-07-27 (user chose Q3 = B) +- **Workspace Root**: K:\Development\Projects\SlpModularCms + +## Reverse Engineering Status +- [x] Reverse Engineering — Completed on 2026-07-27 +- **Artifacts Location**: aidlc-docs/_shared/reverse-engineering/ (all 8 artifacts regenerated + timestamp) +- **Verified by execution**: Release build 0 errors / 50 warnings; 219 backend tests pass; 213 frontend tests pass; `pnpm run lint` **fails** (5 errors, 1 warning); 2 high-severity transitive package advisories + +## Code Location Rules +- **Application Code**: Workspace root (NEVER in aidlc-docs/) +- **Feature Documentation**: aidlc-docs/features/gitea-deployment-workflow/ only +- **Shared Artifacts**: aidlc-docs/_shared/ +- **Structure patterns**: See code-generation.md Critical Rules + +## Language Configuration +- **Documentation Language**: English +- **Conversation Language**: User Language (Dutch) + +## Extension Configuration +| Extension | Enabled | Decided At | +|---|---|---| +| Security Baseline | Yes (blocking) | Requirements Analysis | +| Property-Based Testing | No | Requirements Analysis | + +## Operations Configuration +- **Include Operations Phase**: Yes +- **Decided At**: Requirements Analysis + +## Scope Decisions (from feature-selection.md) +- **Public website**: documentation/instructions only — where the website build lands in `wwwroot/`, how it coexists with `wwwroot/admin/`, and what a per-website workspace must deliver. The website's own build/deploy workflow stays out of scope (Q4 = A). +- **Environments**: local, test, production only. +- **Observability stack**: UptimeRobot (uptime), Umami (analytics), console logging + Sentry (logging/errors). +- **Deployment constraint**: upload as a published .NET application; no server configuration may be required. +- **Reference**: existing working Gitea Actions setup at `K:\Development\SlpSoftware\Projects\SlpSoftware` (React/Vite) is the starting point. +- **Health check endpoint**: IN SCOPE (decided 2026-07-27). **Liveness only** — `AddHealthChecks()` + `MapHealthChecks("/health")`, no package needed and **no database check** (Q17 = A / D-21, superseding the earlier note that `AddDbContextCheck` might be included). `/health` must be added to `AvailabilityMiddleware._bypassPrefixes` so the availability gate cannot return 503 for it. Health = infrastructure liveness; Availability/capabilities = CMS domain state — these stay strictly separate. + +> **Note**: this section records the earliest scope decisions. The authoritative and complete decision set is `inception/requirements/requirements.md` § 3 (D-01…D-32) — in particular, Q4 = C changed the public website from living directly in `wwwroot/` to `wwwroot/web/`. + +## Stage Progress + +### INCEPTION +- [x] Workspace Detection — Complete +- [x] Reverse Engineering — Complete, approved 2026-07-27 (full rerun of all 8 `_shared/` artifacts) +- [x] Requirements Analysis — Complete, approved 2026-07-27. 24 FRs (FR-24 added at Application Design), 10 NFRs, 32 decisions, 7 assumptions, 4 open items, 4 documented security deviations. Two question rounds: `requirement-verification-questions.md` (25 Q) and `requirement-clarification-questions.md` (5 Q). +- [x] User Stories — **SKIP** (infrastructure/operations work; no new end-user functionality or persona. Offered at Requirements Analysis approval, not requested.) +- [x] Workflow Planning — Complete, approved 2026-07-27. Artifact: `inception/plans/execution-plan.md` +- [x] Application Design — Complete, approved 2026-07-27. 14 code components (9 new, 5 modified) + 2 workflow components. Artifacts in `inception/application-design/`. Two composition conflicts found and carried to Unit 2. Added FR-24, closed OPEN-02. +- [x] Units Generation — Complete (awaiting approval). 7 units in 4 execution rounds. Artifacts: `unit-of-work.md`, `unit-of-work-dependency.md`, `unit-of-work-story-map.md` + +### CONSTRUCTION +Units finalised at Units Generation (see `inception/application-design/unit-of-work.md`): +U1 Hosting & Serving · U2 Data Durability · U3 Security Headers & CSP · U4 Observability · U5 CI Workflow & Gates · U6 Deploy Workflow · U7 Documentation + +Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 + U6 · **R4** = U7. One commit per unit; single PR at the end (Q6 = A). + +- [~] Functional Design — **EXECUTE for U1, U2, U3, U4**; SKIP for U5, U6, U7. **U1 ✅ U2 ✅** approved 2026-07-27 +- [ ] NFR Requirements — **SKIP (all units)** — already comprehensively captured in `requirements.md` § 5 and § 6 +- [ ] NFR Design — **EXECUTE for U3, U4**; SKIP for the rest. *Deliberate deviation from the default NFR-Requirements/NFR-Design coupling — rationale in the execution plan.* +- [ ] Infrastructure Design — **EXECUTE for U6, U7**; SKIP for the rest +- [~] Code Generation — **EXECUTE** (all 7 units, each built and tested before its completion message). **U1 ✅ U2 ✅** generated and verified 2026-07-27 — build 0 errors, 253 backend tests pass (was 219) +- [ ] Build and Test — **EXECUTE** + +### OPERATIONS +- [ ] Deployment Setup — **EXECUTE** +- [ ] Monitoring Setup — **EXECUTE** +- [ ] Production Readiness Validation — **EXECUTE** (includes the `dotnet-appsettings` compliance gate) + +## Execution Plan Summary +- **Risk Level**: High — three destructive-and-silent failure modes (customer website loss, Data Protection key-ring loss, automatic migration against production) +- **Stages to Execute**: Functional Design (×4: U1–U4), NFR Design (×2: U3, U4), Infrastructure Design (×2: U6, U7), Code Generation (×7), Build and Test, Deployment Setup, Monitoring Setup, Production Readiness Validation +- **Stages to Skip**: User Stories (no end-user functionality), NFR Requirements (already captured), plus per-unit skips as listed above + +## Current Status +- **Lifecycle Phase**: CONSTRUCTION +- **Current Stage**: Code Generation complete for Round 1 (U1 Hosting & Serving, U2 Data Durability) +- **Next Stage**: Round 2 — U3 Security Headers & CSP + U4 Observability, starting with Functional Design +- **Status**: Awaiting Round 1 code approval + +## Round 1 Verification Record (2026-07-27) +- `dotnet build SlpModularCms.sln -c Release` — 0 errors +- Backend tests — **253 passed, 0 failed** (Core 83, Availability 82, Identity 37, Master 51); baseline was 219 +- New EF migration `20260727203036_AddDataProtectionKeys` — verified purely additive +- Embedded placeholder resource name verified against the compiled assembly manifest +- **Carried to phase-level Build and Test**: composed-startup behaviour that needs a running host and a real database — `/admin` trailing-slash redirect, 404-vs-HTML for missing assets, SPA fallback and placeholder resolution, `/health` while availability-disabled, `MigrateCoreDatabase` against SQL Server, and both hosts starting +- **Deviation**: U1 plan Step 11 (`StaticContentTests`) not implemented — the code lives in `SlpModularCms.Api`, which has no test project by convention; behaviour carried to Build and Test instead. Recorded in the unit's `generation-summary.md` diff --git a/aidlc-docs/features/gitea-deployment-workflow/audit.md b/aidlc-docs/features/gitea-deployment-workflow/audit.md new file mode 100644 index 0000000..6693801 --- /dev/null +++ b/aidlc-docs/features/gitea-deployment-workflow/audit.md @@ -0,0 +1,802 @@ +# Audit Log — Gitea Deployment Workflow + +## Initial Request + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "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, datwordt per werbsite 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." + +**AI Response**: "Started AI-DLC workflow. Loaded common rules and extension opt-in files. Performed workspace scan and reference-workspace analysis. Presented feature resolution, language preference, reverse-engineering staleness handling, and public-website scope questions in `aidlc-docs/feature-selection.md`." + +**Context**: INCEPTION — Workspace Detection. Multi-feature workspace detected (`active-features.md` exists, 5 existing features). Request does not match any existing feature; proposed new feature `gitea-deployment-workflow`. + +--- + +## Workspace Detection — Findings + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated analysis step)" + +**AI Response**: "Recorded workspace state and reference-workflow analysis." + +**Context**: INCEPTION — Workspace Detection + +### Workspace State +- **Existing Code**: Yes +- **Programming Languages**: C# (.NET 10), TypeScript/React +- **Build System**: MSBuild / `dotnet` (solution `SlpModularCms.sln`); pnpm + Vite for `frontend/` +- **Project Structure**: Modular monolith — `src/SlpModularCms.Api` (host), `src/SlpModularCms.Api.Slave`, `src/SlpModularCms.Core`, `src/SlpModularCms.Modules.{Master,Identity,Availability}` plus mirrored test projects; `frontend/` admin SPA outside `src/` +- **Workspace Root**: `K:\Development\Projects\SlpModularCms` +- **Project Type**: Brownfield +- **Git Branch**: `master` +- **CI/CD present**: No `.gitea/` directory exists in this repo yet + +### Reverse Engineering Artifact Status +- Artifacts exist in `aidlc-docs/_shared/reverse-engineering/` (8 files), dated **2026-06-16T20:30:00Z** +- Assessed as **stale**: commits after that date changed deployment-relevant structure — `master-cms-module`, `local-dev-master-slave-setup`, solution reorganization (`754bd97`), and single-host serving (`3885703`) +- Staleness handling deferred to the user via Question 3 in `feature-selection.md` + +### Reference Workflow Analysis (`K:\Development\SlpSoftware\Projects\SlpSoftware`) +Existing, working Gitea Actions setup for a React/Vite frontend — to be used as the starting point for this feature: + +- `.gitea/workflows/continuous_integration.yaml` — triggers on `pull_request`, `push` to `master`, and `workflow_dispatch` (with a `deploy_production` boolean input). Jobs: `config` (re-exports `env:` values as job outputs so they are usable in a reusable workflow's `with:` block), `prepare`, `build` (test env), `build-production` (separate build because `VITE_APP_ENV` is build-time), `test` (lint + unit tests), `deploy-test`, `deploy-production`. +- `.gitea/workflows/deploy.yaml` — reusable `workflow_call` workflow taking `artifact_name`, `environment`, `deploy_path`; downloads the artifact and uploads it over SCP using `sshpass` in a plain shell step (the `appleboy/scp-action` container action fails on the Podman-backed runner with a 409 attach error). +- Configuration split: non-secret values (Umami script URL / website IDs per environment, Sentry DSN) as Gitea Actions **variables**; SSH credentials as **secrets**. +- Operations artifacts under `aidlc-docs/features/react-frontend/operations/` include `deployment-instructions.md`, `deployment-plan.md`, `rollback-plan.md`, nginx example configs, Umami podman-compose setup, `monitoring-plan.md`, `monitoring-setup.md`, `umami-setup.md`, and a `production-readiness-checklist.md`. + +### Key Difference From The Reference +The reference deploys a **static `dist/` folder** to an nginx webroot on self-hosted Raspberry Pis. This feature must instead deploy a **published .NET application** to shared hosting with **no server configuration possible**, which is why the API serves the public website (`/`), the admin SPA (`/admin`), and the API (`/api/v1`) from a single host process. + +--- + +## Workspace Detection — Feature Resolution Answers + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — answers recorded in `aidlc-docs/feature-selection.md`: Q1 = "A, maak ook een nieuwe feature-branch aan voor deze feature.", Q2 = "A", Q3 = "B", Q4 = "A" + +**AI Response**: "Validated answers — no contradictions or ambiguities detected. Created git branch `feature/gitea-deployment-workflow`, created `aidlc-state.md` for the feature, and registered the feature in `aidlc-docs/active-features.md` with status 🔵 Inception. Proceeding to Reverse Engineering (full rerun)." + +**Context**: INCEPTION — Workspace Detection complete. Decisions: (1) new feature `gitea-deployment-workflow` on its own feature branch; (2) Documentation Language = English, Conversation Language = Dutch; (3) full reverse engineering rerun of all 8 `_shared/` artifacts; (4) public website handled as documentation/instructions only — its build/deploy workflow stays out of scope. + +--- + +## Conflict Detection + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated analysis step)" + +**AI Response**: "Compared affected components against other non-complete features." + +**Context**: INCEPTION — Workspace Detection. One other active feature: `tech-debt-backlog` (🔵 Inception), affecting `Modules.Master` and `Frontend`. Overlap with this feature is limited to `Frontend` (this feature only touches its build/deploy configuration, not its source). Informational only — does not block progress; developer handles at merge time. + +--- + +## Reverse Engineering — Start + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — stage entered automatically after Workspace Detection)" + +**AI Response**: "Loaded `inception/reverse-engineering.md`. Started full rerun of all 8 shared artifacts per the user's Q3 = B decision." + +**Context**: INCEPTION — Reverse Engineering. Full rerun, not a targeted refresh. + +--- + +## Reverse Engineering — Complete + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated analysis and generation)" + +**AI Response**: "Regenerated all 8 artifacts plus the timestamp file in `aidlc-docs/_shared/reverse-engineering/`. Updated the feature's `aidlc-state.md` with Reverse Engineering Status. Presented the completion message and awaiting explicit approval." + +**Context**: INCEPTION — Reverse Engineering complete. + +### Scope of analysis +122 C# files (excluding `bin`/`obj`) and 111 TypeScript/TSX files under `frontend/src`, plus solution, project, configuration and lock files. + +### Verification performed by execution (not inferred) +- `dotnet build SlpModularCms.sln -c Release` — 0 errors, 50 warnings +- `dotnet test SlpModularCms.sln -c Release` — 219 tests: Core 54, Identity 37, Availability 78, Master 50; 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` — `Microsoft.OpenApi` 2.0.0 (High), `System.Security.Cryptography.Xml` 10.0.9 (High) + +### Key deployment-relevant findings +1. No CI/CD exists — the repository has no `.gitea/workflows/`. +2. `dotnet publish` on `SlpModularCms.Api` requires Node and pnpm: the `BuildAndCopyAdminFrontend` target runs `pnpm install --frozen-lockfile` and `pnpm build` before publish. +3. The admin SPA bundle is environment-specific — `frontend/src/lib/config.ts` requires `VITE_API_BASE_URL` as an absolute URL, so test and production cannot share one artifact unless a same-origin default is introduced. +4. No health-check endpoint exists for uptime monitoring; the three anonymous candidates (`/api/v1/Availability/status`, `/api/v1/System/capabilities`, `/`) each prove something different, and only `capabilities` goes red when an instance is deliberately disabled. +5. The public website at `/` is not behind the availability gate — static files short-circuit before `AvailabilityMiddleware` is installed. +6. `ModuleOrchestrator` discovers modules by globbing `SlpModularCms.Modules.*.dll` in the app base directory, so deployment content determines capability; load failures are logged, not fatal. +7. Data Protection uses the default file-system key ring with no persistent store — a redeploy that discards it makes stored slave API keys unreadable. +8. No forwarded-headers middleware while `UseHttpsRedirection()` runs early — relevant behind a hosting provider's TLS-terminating proxy. +9. `ApplicationDbContext` migrations are never applied automatically, while both module contexts self-migrate at startup. +10. No `Test` environment configuration exists (`appsettings.Test.json` absent, no `ASPNETCORE_ENVIRONMENT` value defined for test). +11. Observability is entirely greenfield — no Sentry, Umami, structured logging, metrics or tracing anywhere. Production log level is `Warning`. +12. No `packages.lock.json` for any .NET project, so NuGet restore is not reproducible (the frontend is locked via `pnpm-lock.yaml`). +13. `AvailabilityMiddleware.IsAdminBypass` reads the JWT without validating its signature, so the availability gate is bypassable by an unauthenticated caller (not a privilege escalation — protected endpoints still authenticate). + +--- + +## Reverse Engineering — Change Requested + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "De availability is geen health check. Die zou dan nog apart moeten worden ingeregeld. De availabillity en capabilities is functionaliteit van de CMS zelf en/of tussen slave en master." + +**AI Response**: "Correction accepted and applied. The earlier framing of `/api/v1/Availability/status` and `/api/v1/System/capabilities` as candidate monitoring endpoints was wrong. Both are CMS domain functionality — availability is the product's own on/off state (local switch plus master gate) and capabilities reports loaded modules; both also serve the master↔slave protocol. Neither reflects application health and neither may be repurposed for monitoring. Updated `api-documentation.md` (§ Observability endpoints rewritten), `architecture.md` (deployment-relevant observation 4) and `code-quality-assessment.md` (technical-debt item 4) to state that a dedicated health check is a separate concern still to be designed: its own endpoint, outside `/api/v1` domain routing and outside the availability gate, reporting infrastructure liveness (process up, database reachable, migrations applied) rather than product state." + +**Context**: INCEPTION — Reverse Engineering. Supersedes deployment-relevant finding 4 as originally logged in the "Reverse Engineering — Complete" entry above. Carried forward as a requirement for the Operations phase: a health-check endpoint must be built for UptimeRobot rather than reusing domain endpoints. + +--- + +## Reverse Engineering — Scope Addition: Health Check Endpoint + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Is er in .NET niet iets simpels om health checks toe te toevegen? Iets van AddHealthChecks()? Als het zo simpel is mag het erbij omdat het wel kan helpen bij deze feature. Anders wordt dat los opgepakt" + +**AI Response**: "Confirmed it is that simple, and verified package availability. `builder.Services.AddHealthChecks()` and `app.MapHealthChecks(\"/health\")` require NO package — both are in the ASP.NET Core shared framework; default response is plain text `Healthy`/200 or `Unhealthy`/503, which is directly consumable by UptimeRobot. A database probe costs one package, `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` 10.0.9 (verified available on nuget.org and in line with the existing 10.0.x dependencies), plus `.AddDbContextCheck()`, which does `CanConnectAsync` by default and can optionally report pending migrations — extra valuable here because `ApplicationDbContext` never migrates automatically. Flagged one codebase-specific requirement: `MapHealthChecks` registers an endpoint and middleware runs first, so `AvailabilityMiddleware` would return 503 for `/health` on a switched-off instance — reproducing exactly the conflation the user corrected. `/health` must therefore be added to `AvailabilityMiddleware._bypassPrefixes`. Recorded as in scope in `aidlc-state.md` and updated `api-documentation.md` and `code-quality-assessment.md` accordingly." + +**Context**: INCEPTION — Reverse Engineering. Scope decision: a dedicated health-check endpoint is IN SCOPE for this feature (rather than being picked up separately). Separation of concerns is explicit: `/health` reports infrastructure liveness; Availability and capabilities remain CMS domain functionality and are never used for monitoring. + +--- + +## Reverse Engineering — Approved + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Reverse Engineering approved. Marked complete in `aidlc-state.md` and proceeded to Requirements Analysis." + +**Context**: INCEPTION — Reverse Engineering approved, including the health-check scope addition and the availability/capabilities correction. + +--- + +## Requirements Analysis — Intent Analysis + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated analysis step)" + +**AI Response**: "Loaded architecture.md, component-inventory.md and technology-stack.md from `_shared/reverse-engineering/`, plus the reference project's operations artifacts (deployment-plan.md, monitoring-plan.md) at `K:\Development\SlpSoftware\Projects\SlpSoftware\aidlc-docs\features\react-frontend\operations\`. Performed intent analysis and generated 25 clarifying questions." + +**Context**: INCEPTION — Requirements Analysis, Steps 1–6. + +### Intent Analysis +- **Request Clarity**: Clear on goals and tooling choices, incomplete on hosting/transport specifics — the user named the target stack (Gitea Actions, UptimeRobot, Umami, Sentry + console logging), the environments (local/test/production), the reference implementation, and the core constraint (upload as a .NET application, no server configuration possible). What is not yet specified is how the shared host is reached, how migrations and configuration are delivered, and how the public website's `wwwroot` content is protected from the CMS deploy. +- **Request Type**: New Feature (CI/CD and operational tooling), with supporting Enhancement work in the application itself (health-check endpoint, and candidate changes for frontend config, Data Protection key ring, and migration strategy). +- **Initial Scope Estimate**: Multiple Components — new `.gitea/workflows/` definitions, changes to `SlpModularCms.Api` (health checks, bypass list, possibly key ring and migrations), possibly `frontend/src/lib/config.ts`, new `appsettings.Test.json`, plus documentation and operations artifacts. +- **Initial Complexity Estimate**: Moderate-to-Complex. Not algorithmically hard, but the constraint set is unusually tight: one host process serving three surfaces, a customer-owned `wwwroot` that must survive deploys, shared hosting with no server configuration, self-migrating module contexts alongside a manually-migrated Core context, and an environment-specific frontend bundle. +- **Requirements Depth**: **Comprehensive** — justified by the number of interacting constraints, the deployment risk (a wrong `wwwroot` sync destroys a customer's website; a lost Data Protection key ring silently breaks master↔slave), and the fact that several findings require explicit product decisions rather than defaults. + +### Notable reference-project context loaded +- Deploy transport in the reference is `scp` + `sshpass` in a plain shell step, because container-based actions fail on the Podman-backed self-hosted runner with a 409 attach error. Shared hosting is unlikely to offer SSH, so transport is an open question here. +- The reference deliberately splits test (automatic on `master`) from production (manual `workflow_dispatch` with a `deploy_production` checkbox), and builds twice because `VITE_APP_ENV` is build-time. +- The reference uses one Sentry project with environment tags, a self-hosted Umami on `analytics.slpsoftware.nl` with per-environment website IDs, and UptimeRobot for uptime. + +### Questions generated +25 questions in `inception/requirements/requirement-verification-questions.md`, grouped: A hosting/transport (3), B public website in `wwwroot` (2), C workflow setup and gates (4), D database and configuration (4), E monitoring and observability (7), F rollback and reliability (2), G extensions and phasing (3 — security opt-in, PBT opt-in, Operations phase inclusion). + +--- + +## Requirements Analysis — Answers Received, Clarification Round Opened + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — answers recorded in `inception/requirements/requirement-verification-questions.md`: Q1 = "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.", Q2 = B, Q3 = B, Q4 = "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).", Q5 = B, Q6 = A, Q7 = A, Q8 = A, Q9 = "A, B, C, D, E", Q10 = C, Q11 = "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", Q12 = A, Q13 = A, Q14 = A, Q15 = A, Q16 = C, Q17 = A, Q18 = C, Q19 = A, Q20 = B, Q21 = C, Q22 = A, Security extension = A, PBT extension = C, Operations phase = A. + +**AI Response**: "Loaded the security-baseline extension rules (opted in). Validated all answers and detected one direct question from the user plus three genuine conflicts; opened a clarification round in `inception/requirements/requirement-clarification-questions.md` with 5 questions." + +**Context**: INCEPTION — Requirements Analysis, Step 6 contradiction/ambiguity analysis. + +### Extension configuration decided +- Security Baseline: **Enabled** (blocking) — full rules file loaded. +- Property-Based Testing: **Disabled** — rules file deliberately not loaded. +- Operations Phase: **Yes**, decided at Requirements Analysis. + +### Contradictions and ambiguities detected +1. **Q1 answered about the wrong environment, plus a protocol misconception.** The user asked whether FileZilla connecting over SFTP means FTP can be the baseline. It does not: SFTP is file transfer inside an SSH session (proving SSH/SCP works, exactly the reference approach), whereas FTPS is a separate protocol needing its own server. More importantly, Q1 asked about **production** but the answer describes the Pi, which Q2 assigns to **test**. Commit `3885703` cites shared hosting (mijnhostingpartner.nl) as the reason for single-host serving, so where production actually runs is unresolved. +2. **Q11 (single bundle) contradicts Q14/Q15/Q20 (build-time environment tagging).** Same-origin API URL is implementable, but Sentry's `environment` tag and Umami's per-environment website ID are Vite build-time values, so one `dist/` cannot serve both test and production — the very reason the reference project has a separate `build-production` job. Either two builds are accepted or the environment config must become runtime-delivered. +3. **Q22 (`app_offline.htm`) is inapplicable to Q2's chosen test environment.** `app_offline.htm` is an IIS ASP.NET Core Module feature; on the Pi the app runs as a Kestrel process (nginx + systemd), where the file is ignored and an upload would overwrite DLLs of a live process. Also noted: any clean stop takes the public website in `wwwroot/web/` offline too, even though it is unrelated to the CMS deploy. +4. **Blocking security rules expand scope beyond the workflow** (raised for an explicit decision rather than silently expanding or skipping): SECURITY-04 requires HTTP security headers, of which the app currently sets none — and because server configuration is explicitly unavailable, they must be app middleware; a CSP additionally constrains the externally-owned public website and needs explicit exceptions for Umami and Sentry. SECURITY-14 requires alerting plus 90-day log retention, which conflicts with Sentry's free-plan 30-day event retention chosen in Q16. + +### Answers accepted without conflict (noted for the requirements document) +- Q4 = C with the folder named **`web`**, not `site` — final `wwwroot` layout: `admin/`, `web/`, and optionally an API folder if needed. This resolves the `wwwroot` destruction risk structurally rather than by exclusion lists. +- Q10 = C (auto-migrate `ApplicationDbContext` at startup) sits consistently with Q17 = A (liveness-only health check): if the database is unreachable, startup migration fails, so liveness implicitly covers database availability at boot. +- Q3 = B means the toolchain is installed explicitly in the workflow (`actions/setup-dotnet`, `pnpm/action-setup`) rather than assumed present on the runner — which also satisfies SECURITY-10's pinned-tool-version requirement. + +--- + +## Requirements Analysis — Clarification Answers Received + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — answers recorded in `inception/requirements/requirement-clarification-questions.md`: CQ1 = C, CQ2 = C, CQ3 = C, CQ4 = A, CQ5 = B. + +**AI Response**: "All clarifications unambiguous; no further contradictions detected. Generated `inception/requirements/requirements.md` at comprehensive depth. Updated `aidlc-state.md` with Extension Configuration and Operations Configuration." + +**Context**: INCEPTION — Requirements Analysis, Steps 7–8. + +### Clarifications resolved +- **CQ1 = C**: Production runs on the Pi for now, with shared hosting as the eventual target. Transport must be structured so FTPS can be added alongside SSH/SCP without restructuring the workflow. Resolves the Q1 environment mix-up; SSH/SCP is correct for both environments today. +- **CQ2 = C**: Two builds now (reference pattern), with runtime-delivered environment configuration recorded as a possible later improvement. Same-origin API base URL is still adopted for its own sake. +- **CQ3 = C**: Atomic release-directory switch instead of `app_offline.htm`. Supersedes Q22 = A, which was inapplicable to the Pi. +- **CQ4 = A**: Both SECURITY-04 (security headers middleware) and SECURITY-14 (Sentry alert rules) are addressed within this feature, with log retention documented as a deliberate deviation. +- **CQ5 = B**: CSP strict for `/admin` and `/api/v1`, more permissive for the public website. + +### Derived requirement stated rather than asked (ASM-01) +Combining Q4 = C (`wwwroot/web/`) with CQ3 = C (atomic release switch) creates a failure mode neither answer covers on its own: switching the release directory would discard the customer's website along with the old release. `wwwroot/web/` must therefore live outside the swapped release directory and be linked into it. This follows necessarily from the two decisions, so it is recorded as assumption ASM-01 with its rationale and consequence rather than opening a third question round — the user can reject it at approval. + +### Artifact produced +`inception/requirements/requirements.md` — 23 functional requirements, 10 non-functional requirements, 32 traced decisions (D-01…D-32), 7 assumptions, 4 open items, 4 documented security deviations, and a full SECURITY-01…15 compliance assessment. + +### Security Compliance summary (Security Baseline extension, enabled and blocking) +Addressed: SECURITY-01, 03, 04, 06, 09, 10, 11, 13, 14 (with deviation DEV-01). Pre-existing and unchanged: SECURITY-05, 08, 12, 15. N/A: SECURITY-02 (no load balancer, API gateway or CDN in this architecture). Partially N/A: SECURITY-07 (no cloud networking; applicable parts documented as host setup). + +**No blocking security findings** — every rule is either addressed by a requirement, pre-existing and unchanged, marked N/A with rationale, or recorded as an explicitly accepted deviation (DEV-01…DEV-04, all pre-existing or cost-driven, none introduced by this feature). + +Two security-relevant findings are logged as open items rather than silently dropped: OPEN-01 (correlation/request ID required by SECURITY-03 does not exist yet) and OPEN-02 (`AvailabilityMiddleware.IsAdminBypass` reads the JWT without validating its signature — pre-existing, needs an explicit owner). + +--- + +## Requirements Analysis — Approved + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Requirements approved (User Stories option offered and not taken). Marked complete in `aidlc-state.md` and proceeded to Workflow Planning." + +**Context**: INCEPTION — Requirements Analysis approved, including assumption ASM-01 and the four documented security deviations. + +--- + +## Workflow Planning — Complete + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Loaded all prior context (4 reverse-engineering artifacts, requirements.md, both question files with answers). Performed transformation-scope, change-impact, component-relationship and risk analysis. Created `inception/plans/execution-plan.md` with a validated Mermaid workflow diagram. Updated `aidlc-state.md` with the full stage matrix, execution plan summary and current status." + +**Context**: INCEPTION — Workflow Planning, Steps 1–8. + +### Risk assessment: HIGH +Rated High rather than Medium because three failure modes are destructive **and** silent: +1. An atomic release switch carrying `wwwroot/web/` inside the swapped directory would destroy the customer's public website on every deploy (ASM-01) — data loss, not a bug. +2. Losing the Data Protection key ring makes stored slave API keys undecryptable; the symptom resembles a network fault and would be misdiagnosed. +3. Automatic Core migrations at startup (FR-11) make deployment self-contained and simultaneously remove the human gate before a migration runs against production — which is why FR-20 (pre-deploy backup) and forward-compatible migrations are load-bearing rather than optional. +Also: the deploy path cannot be fully tested in CI (needs the real Pi, SSH credentials and a database), and CSP failures only manifest in a real browser on pages this repository does not own. + +Rollback complexity: Moderate. Testing complexity: Complex. + +### Stage decisions +- **User Stories — SKIP**: infrastructure/operations work with no new end-user functionality or persona; the website-builder "persona" is properly served by FR-09's documented contract. Offered at Requirements Analysis approval and not requested. +- **Application Design — EXECUTE**: genuine component-boundary decisions — whether cross-cutting registrations (health checks, security headers, Data Protection) belong in `Core` (inherited by both hosts including the Slave) or in `Api`; redesigning static-file serving for two mounts and two fallbacks; a CSP path-scoping mechanism that does not exist yet; and a deploy-transport abstraction admitting FTPS later (NFR-09). +- **Units Generation — EXECUTE**: 7 units with a load-bearing ordering constraint — durability changes must precede the first automated deploy, and quality-gate fixes must precede switching blocking gates on. +- **Functional Design — EXECUTE units 2, 3, 4 only**: those contain real behavioural logic; units 1, 5, 6, 7 are lint/package changes, declarative YAML and documentation. +- **NFR Requirements — SKIP all units**: already captured comprehensively with traceability in `requirements.md` § 5 (NFR-01…10) and § 6 (full SECURITY-01…15 assessment); the tech stack is fixed. +- **NFR Design — EXECUTE units 3, 4 only**, recorded as a **deliberate deviation** from the workflow's default coupling (NFR Design skipped when NFR Requirements is skipped). Rationale: for these two units the NFR *is* the deliverable — CSP composition and path-scoping plus HSTS-behind-proxy behaviour (SECURITY-04), and structured-logging shape, correlation ID (OPEN-01), alertable-event definition and PII exclusion (SECURITY-03, SECURITY-14). Those are pattern decisions, not requirement decisions, so skipping requirements while designing patterns is the correct split rather than an oversight. +- **Infrastructure Design — EXECUTE units 6, 7 only**: the host layout (release-directory scheme, where `wwwroot/web/` lives so ASM-01 holds, symlink/mount strategy, process restart, backup placement, transport abstraction) and the website contract that depends on it. Produces documented procedure rather than IaC, because NFR-01 forbids server configuration. +- **Operations — all three stages EXECUTE**: Operations Configuration = Yes, and for this feature it is the centre of gravity (FR-17, FR-19, FR-20, FR-23), including the `dotnet-appsettings` compliance gate at Production Readiness Validation. + +### Proposed unit sequence (finalised at Units Generation) +1. Quality Gate Prerequisites (FR-21, FR-22) — first, so blocking gates are not red on arrival +2. Hosting Layout & Data Durability (FR-07, FR-08, FR-10, FR-11, FR-12) — before any deploy exists +3. HTTP Security Headers & CSP (FR-18) +4. Observability Integration (FR-13, FR-14, FR-15, FR-16) +5. CI Workflow (FR-01, FR-05) +6. Deploy Workflow (FR-02, FR-03, FR-04, FR-06, FR-20) +7. Repository Documentation (FR-09 + README/`.env.example` updates) + +### Coordination note +`SlpModularCms.Api.Slave` inherits every `Core` change and has no test project by design, so it is verified at Build and Test by actually starting it. The `/health` bypass in `Modules.Availability` must land in the same unit as the health endpoint, or `/health` returns 503 on a disabled instance — the exact conflation the user corrected during Reverse Engineering. + +### Security Compliance (Security Baseline extension — enabled, blocking) +No new blocking findings at this stage. Workflow Planning produces a plan, not code or configuration; the SECURITY-01…15 assessment recorded in `requirements.md` § 6 remains current and is carried forward unchanged. SECURITY-10 (pinned tool versions, no `latest` tags) and SECURITY-13 (access-controlled, auditable pipeline definitions) are explicitly reflected in the plan's Unit 5 and Unit 6 scope. + +--- + +## Workflow Planning — Approved + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Execution plan approved, including the User Stories and NFR Requirements skips and the deliberate NFR Design coupling deviation. Proceeded to Application Design." + +**Context**: INCEPTION — Workflow Planning approved. Next stage: Application Design. + +--- + +## Application Design — Plan Created + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Analyzed context and inspected both host `Program.cs` files to establish the composition baseline. Created `inception/plans/application-design-plan.md` with 8 design steps and 12 embedded design questions." + +**Context**: INCEPTION — Application Design, Steps 1–6. + +### Baseline established by inspection +`SlpModularCms.Api/Program.cs` and `SlpModularCms.Api.Slave/Program.cs` are near-identical, differing only in the static-file and SPA-fallback section. This feature adds the same new registrations to both, which would grow that duplication — raised as Question 9 rather than silently choosing. + +### Question areas +1. Home project for the four new cross-cutting concerns (`Core` versus host) — consequential because `Core` is inherited by the Slave +2. What the Slave host should and should not receive +3. Static-file serving model for the two mounts +4. Security-headers placement in the pipeline — noting that static files short-circuit, so headers registered after them never reach the public website +5. Configurability surface for the security headers +6. CSP path-scoping mechanism +7. Which `DbContext` hosts the Data Protection keys +8. Migration-at-startup failure behaviour, and its interaction with the liveness health check +9. Whether to extract the duplicated host composition +10. Sentry registration shape +11. Deploy transport abstraction (workflow layer, not C#) +12. OPEN-02 ownership — fold into this feature, defer to `tech-debt-backlog`, or accept as documented risk + +--- + +## Application Design — Answers Received, Follow-up Round Opened + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — answers in `inception/plans/application-design-plan.md`: Q1 = A, Q2 = "A, Want de Slave is wel een API die laat zien hoe een klant-API eruit kan komen te zien.", Q3 = A, Q4 = C, Q5 = B, Q6 = B, Q7 = A, Q8 = A, Q9 = A, Q10 = B, Q11 = B, Q12 = A. + +**AI Response**: "Analyzed all 12 answers per Step 8. Ten are unambiguous and mutually consistent. Two required follow-up, added to the plan as Part 3." + +**Context**: INCEPTION — Application Design, Steps 7–9. + +### Decisions accepted +- **Q1 = A**: all four new cross-cutting concerns live in `Core` as extension methods — both hosts get identical behaviour, no duplication. +- **Q2 = A**: the Slave receives everything except the static-file mounts. User rationale recorded: the Slave demonstrates what a customer-facing API instance looks like, so it should behave like production rather than like a stripped-down test harness. This elevates the Slave from "local dev tool" to "reference instance", which is a meaningful reframing. +- **Q3 = A**: two explicit `UseStaticFiles` registrations, each with its own `PhysicalFileProvider` and `RequestPath`. +- **Q7 = A**: Data Protection keys live in `ApplicationDbContext`, which will implement `IDataProtectionKeyContext`. Requires a new Core migration — acceptable because FR-11 now migrates that context automatically. +- **Q8 = A**: fail fast on migration failure. Consistent with the liveness-only health check (D-21): a process that cannot migrate does not start, so `/health` stops answering and UptimeRobot goes red — the monitoring signal is meaningful precisely because of this choice. +- **Q9 = A**: the two `Program.cs` files stay separate. With Q1 = A the shared logic is in `Core` extension methods, so what remains duplicated is the explicit list of calls — which is intentional readability, not accidental duplication. +- **Q10 = B**: Sentry and logging configuration registered separately, so structured logging works without Sentry. +- **Q11 = B**: one reusable workflow per transport (`deploy-scp.yaml`, later `deploy-ftps.yaml`) with an identical input interface. +- **Q12 = A**: **OPEN-02 is folded into this feature.** `AvailabilityMiddleware.IsAdminBypass` will validate the token properly instead of reading it unvalidated. Lands in the same unit as the `/health` bypass, since both touch the same middleware. This closes a SECURITY-08 finding and requires a new functional requirement — to be added as FR-24 and OPEN-02 marked resolved. + +### Follow-ups raised +1. **Q4 = C would weaken security if implemented literally.** The user chose "headers only on SPA/HTML responses, not assets". That is correct for `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy`, but wrong for two headers: `X-Content-Type-Options: nosniff` exists specifically to stop MIME-sniffing of **non-HTML** resources, so restricting it to HTML removes it exactly where it does its job; and `Strict-Transport-Security` is a host-level transport directive that a visitor whose first request is an asset would never receive. Follow-up offers per-header scoping as the recommended reading of the same intent. +2. **Q5 = B and Q6 = B appear to conflict** (minimal configuration versus a configurable path-to-policy rule list). One reading makes them consistent: policy *definitions* in code (Q5), path-to-policy *assignment* and environment-specific origins in configuration (Q6). Presented with a concrete `appsettings` shape for confirmation rather than assumed. + +--- + +## Application Design — Follow-up Answers and Artifacts Generated + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — follow-up answers in `inception/plans/application-design-plan.md` Part 3: FU1 = A, FU2 = A. + +**AI Response**: "Both follow-ups resolved. Updated `requirements.md` (added FR-24, closed OPEN-02, upgraded SECURITY-08 to Improved, count now 24 FRs). Generated all five Application Design artifacts and marked every plan step complete." + +**Context**: INCEPTION — Application Design, Steps 10–12. + +### Follow-up resolutions +- **FU1 = A**: per-header scoping. `X-Content-Type-Options` and `Strict-Transport-Security` apply to **all** responses; `Content-Security-Policy`, `X-Frame-Options` and `Referrer-Policy` to HTML responses only. This preserves the user's intent from Q4 = C while closing the gap that a literal reading would have created — `nosniff` exists precisely to protect non-HTML resources, so restricting it to HTML would have removed it where it does its job. +- **FU2 = A**: confirmed the synthesis of Q5 = B and Q6 = B — policy *definitions* in code, path *assignment* and environment-specific *origins* in configuration. A misconfiguration can misroute a path but cannot invent a broken policy. + +### Requirements updated +- **FR-24 added**: validate the token in the availability gate's admin bypass (Q12 = A), replacing the unvalidated `ReadJwtToken` call. +- **OPEN-02 closed**: folded into this feature, landing in the same unit as the `/health` bypass. +- **SECURITY-08 upgraded** from "Pre-existing, unchanged" to "**Improved**". +- Totals now: 24 FRs, 10 NFRs, 32 decisions, 7 assumptions, 3 remaining open items, 4 documented deviations. + +### Artifacts generated +`inception/application-design/`: `components.md`, `component-methods.md`, `services.md`, `component-dependency.md`, `application-design.md`. + +**14 code components** (9 new, 5 modified) plus 2 workflow components. All in `Core` per Q1 = A except the static mounts, which are `Api`-only per Q2 = A. + +### TWO CONFLICTS DISCOVERED DURING DESIGN +Both found by tracing composition order; both would have produced code that looks correct while achieving nothing. + +1. **Duplicate `AddDataProtection()` would silently defeat FR-12.** `AvailabilityModule.RegisterServices` and `MasterModule.RegisterServices` each call `services.AddDataProtection()`. Module registration runs *after* the host's, so those bare calls would override the persistent key store from `AddCmsDataProtection()`. FR-12 would appear implemented, registration tests would pass, and the key ring would still be ephemeral — so the first atomic release switch would silently break master↔slave trust in a way presenting as a network fault. Resolution: remove both module calls; host configures once. Assigned to Unit 2 with a test asserting the persistent store survives module registration. + **Second-order finding**: the Data Protection **application discriminator must be set explicitly** — it defaults to deriving from the content root path, which changes on every atomic release-directory switch (FR-06), defeating FR-12 by a different route. +2. **`AvailabilityMiddleware` runs before `UseAuthentication()`**, so `HttpContext.User` is unpopulated when the admin bypass is evaluated. FR-24 therefore has two viable implementations — validate the token in the middleware with shared `TokenValidationParameters`, or move authentication earlier (smaller change, wider blast radius across all modules). Recorded rather than decided; resolved in Functional Design for Unit 2. + +### Additional design obligations recorded +- `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`. +- An unknown CSP policy name must fail at **startup**, not degrade per request (SECURITY-15, fail closed). + +### Security Compliance (Security Baseline extension — enabled, blocking) +Addressed: SECURITY-01, 03, 04, 06, 09, 10, 11, 13, 14 (with DEV-01). **Improved: SECURITY-08** (FR-24 fixes a pre-existing finding). Unchanged: SECURITY-05, 12, 15. N/A: SECURITY-02. Partially N/A: SECURITY-07. + +**No blocking security findings.** + +--- + +## Application Design — Approved + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Application Design approved, including FR-24, the two composition conflicts carried to Unit 2, and the additional design obligations. Proceeded to Units Generation Part 1 (Planning)." + +**Context**: INCEPTION — Application Design approved. Next stage: Units Generation. + +--- + +## Units Generation — Plan Created (Part 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Created `inception/plans/unit-of-work-plan.md` with 6 decomposition steps and 8 questions covering unit boundaries, sequencing, completion criteria, version control and artifact mapping." + +**Context**: INCEPTION — Units Generation, Part 1 Steps 1–5. + +### Question areas +1. Whether the proposed 7-unit split holds +2. Whether Unit 1 (lint fixes, package pins) warrants its own unit +3. Whether Unit 2 is too large — it bundles serving, health/gate and data-durability concerns whose only commonality is that all must land before the first deploy +4. Sequencing: strictly serial versus grouping independent units into one approval round +5. Per-unit definition of done, noting that Q9 of the requirements deliberately excluded a coverage gate +6. Commit and review granularity on `feature/gitea-deployment-workflow` — nothing has been committed yet; all aidlc documentation is currently uncommitted working-tree changes +7. What the mandatory `unit-of-work-story-map.md` maps to, given User Stories was skipped — proposed mapping the 24 FRs as the equivalent +8. Whether documentation belongs in Construction Unit 7 or the Operations phase, given Operations already produces deployment, rollback, monitoring and readiness documents + +--- + +## Units Generation — Answers Received and Artifacts Generated (Part 2) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "ik heb de vragen beantwoord" — answers in `inception/plans/unit-of-work-plan.md`: Q1 = C, Q2 = B, Q3 = B, Q4 = B, Q5 = B, Q6 = A, Q7 = A, Q8 = A. + +**AI Response**: "Analyzed all answers per Step 7 — no contradictions or ambiguities found, so no follow-up round was needed. Executed the plan and generated all three unit artifacts. Marked every plan step complete and updated `aidlc-state.md`." + +**Context**: INCEPTION — Units Generation, Part 1 Steps 6–11 and Part 2 Steps 12–15. + +### Decomposition outcome +Two changes to the proposed split that cancel out numerically — **still 7 units**, but with boundaries drawn along the work rather than along the deadline: +- **Q3 = B**: the oversized Unit 2 split into **U1 Hosting & Serving** and **U2 Data Durability**. It had bundled serving, middleware and persistence work whose only commonality was "must land before the first deploy". +- **Q2 = B**: the quality-gate prerequisites (5 lint fixes, 2 package pins) merged into the CI unit, so gates and their prerequisites land in one commit and the pipeline is never red on arrival. + +Final units: U1 Hosting & Serving · U2 Data Durability · U3 Security Headers & CSP · U4 Observability · U5 CI Workflow & Gates · U6 Deploy Workflow · U7 Documentation. + +### Execution rounds (Q4 = B) +R1 = U1 + U2 (mutually independent) · R2 = U3 + U4 (tightly coupled — U3's CSP needs U4's origins) · R3 = U5 + U6 (one shared input interface) · R4 = U7. One commit per unit; single PR at the end (Q6 = A). A round is an approval boundary, not a commit boundary. + +### Consequence of Q2 = B, recorded and mitigated +Merging the lint fixes into U5 means `pnpm run lint` stays failing through U3 and U4, and U4 changes frontend files — so new violations would hide among the five pre-existing ones. Mitigation recorded in `unit-of-work-dependency.md`: run lint on **changed files** during U4. The file sets are disjoint (U5 fixes four component files; U4 touches `main.tsx`, `config.ts` and a new Umami component), so there is no merge risk — only a detection gap. + +### Change to the execution plan's per-unit stage assignment +The plan assigned Functional Design to "units 2, 3, 4" under the pre-split numbering. After the split, **both halves need it**: U1 for the FR-24 pipeline-ordering decision and the missing-`wwwroot/web/` startup behaviour, U2 for the registration-order conflict and migration failure semantics. Functional Design is therefore **×4 (U1–U4)** rather than ×3. Recorded in `unit-of-work-story-map.md`, which is authoritative over the execution plan's pre-split table, and reflected in `aidlc-state.md`. + +### Coverage verification +All 24 functional requirements assigned, none orphaned or duplicated. All 16 design components assigned. Two requirements deliberately split across a boundary: FR-08 (serving precondition in U1, deployment behaviour in U6) and FR-19 (event emission in U4, alert rules in Operations). Every carried-forward item — the two Application Design conflicts, OPEN-01, OPEN-03, ASM-01, and the two additional design obligations — has an owning unit and a resolving stage. + +### Artifacts generated +`inception/application-design/`: `unit-of-work.md`, `unit-of-work-dependency.md`, `unit-of-work-story-map.md`. + +### Security Compliance (Security Baseline extension — enabled, blocking) +No new blocking findings. This stage produces a decomposition, not code. Relevant to the assessment: U2's ordering constraint directly protects SECURITY-01 and SECURITY-13 (key-ring integrity across deploys), U5 carries SECURITY-10 (vulnerability gate, pinned tool versions), and U1 carries the SECURITY-08 improvement (FR-24). The `requirements.md` § 6 assessment remains current. + +--- + +## Units Generation — Approved + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Units approved. Entered the CONSTRUCTION PHASE at Round 1 (U1 Hosting & Serving + U2 Data Durability), starting with Functional Design for both units." + +**Context**: INCEPTION complete. Transition to CONSTRUCTION PHASE. + +--- + +## Functional Design — Plans Created for U1 and U2 (Round 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Created functional design plans and dedicated question files for both Round 1 units, per the rule that plan files contain only checklists and questions live in separate files." + +**Context**: CONSTRUCTION — Functional Design, Steps 1–5, for U1 and U2 together (Round 1 grouping per Q4 = B). + +### Artifacts created +- `construction/plans/u1-hosting-serving-functional-design-plan.md` — 6 steps +- `construction/plans/u1-hosting-serving-fd-questions.md` — 5 questions +- `construction/plans/u2-data-durability-functional-design-plan.md` — 6 steps +- `construction/plans/u2-data-durability-fd-questions.md` — 5 questions + +### U1 question areas +1. **FR-24 implementation approach** — the § 5.2 conflict: validate the token in the middleware with shared `TokenValidationParameters`, versus moving `UseAuthentication()` before the module middleware +2. Behaviour when `wwwroot/web/` is absent — 404, built-in placeholder, or redirect to `/admin` +3. Whether a missing `wwwroot/web/` should be logged at startup, noting it is normal on a fresh install but would mean the customer's website has vanished on a running production environment +4. `/health` response contract — framework default plain text, or JSON with more detail +5. Whether `/admin` should redirect to `/admin/`, since the SPA is built with `base: '/admin/'` and relative references resolve one level too high without the trailing slash + +### U2 question areas +1. Application-discriminator source — the value that must stay stable across atomic release switches, since the default derives from the content root path +2. **Key encryption at rest** — `PersistKeysToDbContext` stores keys unencrypted by default; DPAPI is unavailable on Linux, and X.509 encryption relocates the loss problem to the certificate. SECURITY-01 relevance flagged explicitly +3. Startup behaviour when the database is temporarily unreachable, distinguishing "not up yet" from "migration is broken" — relevant because the app and SQL Server may start together on the Pi +4. Concurrent migration by two starting instances — currently impossible (one instance per database) but worth an explicit assumption +5. Key lifetime and rotation, including whether to document that the key table must never be pruned + +--- + +## Functional Design — Answers Received and Artifacts Generated (U1 and U2, Round 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — U1: Q1 = A, Q2 = B, Q3 = A, Q4 = C, Q5 = A. U2: Q1 = A, Q2 = C, Q3 = C, Q4 = C, Q5 = C. + +**AI Response**: "Analyzed all ten answers per Step 6. No contradictions or vague responses; no clarification round needed. Generated six functional design artifacts (three per unit). Recorded DEV-05 in `requirements.md` and documented one accepted disclosure trade-off. Marked all plan steps complete." + +**Context**: CONSTRUCTION — Functional Design, Steps 6–8, for U1 and U2. + +### U1 decisions +- **Q1 = A — FR-24 approach resolved (§ 5.2 conflict closed)**: validate the token inside `AvailabilityMiddleware` using the same `TokenValidationParameters` as the JWT bearer scheme, resolved from a **single shared source** rather than copied. Chosen over moving `UseAuthentication()` earlier, which would have changed the pipeline for every module including future ones. A drift in which the gate is more permissive than the scheme would silently re-open the hole FR-24 closes, which is why the single-source constraint is a rule (BR-U1-11) rather than a note. +- **Q2 = B**: a built-in placeholder page is served when `wwwroot/web/index.html` is absent. Stored as an **embedded resource**, not a file in `wwwroot/web/` — a file there would sit inside the directory a website workspace owns and overwrites, so it would either be deleted by the first real deployment or mistaken for part of the customer's site. +- **Q3 = A**: a missing `wwwroot/web/` logs a warning at startup with the expected path. Normal on a fresh install, but on a running production instance it means the customer's website has vanished — a warning is visible in Sentry without blocking startup. +- **Q4 = C**: `/health` returns JSON with status, timestamp, version and loaded module names. Directly serves NFR-06: `ModuleOrchestrator` logs rather than throws on module load failure, so an instance can start with reduced capability, and this is the only way to detect that after a deploy without host access. +- **Q5 = A**: `/admin` redirects to `/admin/`, since the SPA is built with `base: '/admin/'` and relative references otherwise resolve one level too high. + +### U2 decisions +- **Q1 = A**: the application discriminator is a **fixed constant in code**, not configuration. The default derives from the content root path, which changes on every atomic release switch — persisting keys in the database while letting the discriminator move would produce keys that are stored but underivable. A constant cannot be forgotten during a host migration or accidentally differ between instances. +- **Q2 = C**: keys stored unencrypted at rest for now, with certificate encryption as a separate follow-up. **Recorded as DEV-05** in `requirements.md` — the one deviation this feature introduces, accepted because it removes a far larger risk than it adds. +- **Q3 = C**: failures are classified — connection failures retry with backoff, migration failures fail immediately. On the Pi the app and SQL Server may start together after a reboot, so a brief unavailability window is normal operation rather than a fault; a broken migration is a fault, and retrying it only delays the inevitable. +- **Q4 = C**: concurrent migration handled by documentation rather than a distributed lock. One instance per database holds by design today; a lock would add failure modes without removing any. Recorded with the condition that automatic startup migration must be revisited **before** any move to multiple instances sharing a database. +- **Q5 = C**: framework default 90-day rotation, plus an explicit documented rule that the keys table must **never** be pruned — the single most destructive maintenance action available against this system, because it looks like harmless housekeeping and permanently breaks every Master↔slave relationship. + +### Accepted trade-off recorded rather than escalated +U1 Q4 = C places the application **version** on an anonymous endpoint, which touches SECURITY-09 hardening. Judged acceptable and documented in `business-rules.md` under BR-U1-17 rather than opened as another question round, on two grounds: module names are **already** publicly exposed by the anonymous `/api/v1/System/capabilities`, so that field adds no new disclosure; and the version field is the primary reason the endpoint exists — confirming which build is live without host access — while an authenticated health endpoint would not work with UptimeRobot. Flagged to the user in the completion message so it can be reversed if unwanted. + +### Artifacts generated +- `construction/u1-hosting-serving/functional-design/`: `business-logic-model.md`, `business-rules.md`, `domain-entities.md` — 22 business rules (BR-U1-01…22) +- `construction/u2-data-durability/functional-design/`: `business-logic-model.md`, `business-rules.md`, `domain-entities.md` — 18 business rules (BR-U2-01…18) + +U1 adds no persisted entity, no migration and no configuration section. U2 adds one entity (the Data Protection keys table), one Core migration, and no configuration section. + +### Security Compliance (Security Baseline extension — enabled, blocking) +- **U1** — SECURITY-05 compliant, **SECURITY-08 improved** (forged-token bypass closed), SECURITY-09 compliant with the documented version disclosure, SECURITY-15 compliant (gate fails closed). +- **U2** — SECURITY-01 **partially compliant, DEV-05** (in-transit enforced, at-rest deferred with compensating controls), SECURITY-03 compliant, SECURITY-09 compliant, **SECURITY-13 improved** (key-ring survival across redeploys is a software-integrity property), SECURITY-15 compliant. + +**No blocking security findings.** DEV-05 is a newly introduced deviation, explicitly accepted with rationale and compensating controls. + +--- + +## Functional Design — Approved (U1 and U2) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to next stage" + +**AI Response**: "Functional Design approved for both Round 1 units, including DEV-05 and the accepted version-disclosure trade-off on `/health`. Proceeded to Code Generation Part 1 (Planning). NFR Requirements, NFR Design and Infrastructure Design are skipped for U1 and U2 per the execution plan." + +**Context**: CONSTRUCTION — Functional Design approved for U1 and U2. + +--- + +## Code Generation — Plans Created for U1 and U2 (Part 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Inspected the actual files to be modified (`ApplicationDbContext`, existing test conventions, both `Program.cs` files) and verified the required package version on nuget.org before writing the plans. Created `u1-hosting-serving-code-generation-plan.md` (13 steps) and `u2-data-durability-code-generation-plan.md` (11 steps)." + +**Context**: CONSTRUCTION — Code Generation, Part 1 Steps 1–6, for U1 and U2. + +### Approval prompt logged before asking +"Code generation plans ready for U1 Hosting & Serving and U2 Data Durability. Review the plans at `construction/plans/u1-hosting-serving-code-generation-plan.md` and `construction/plans/u2-data-durability-code-generation-plan.md`. Approve the plans and proceed to Code Generation (Part 2)?" + +### U1 plan — 13 steps +9 files created, 7 modified. Key design decisions recorded in the plan: +- **Step 1 extracts the JWT validation parameters into a shared factory** consumed by both `AddJwtBearer` and the new admin token validator. This implements the BR-U1-11 single-source constraint — two copies could drift, and a gate more permissive than the bearer scheme would silently re-open the hole FR-24 closes. +- **Step 2 deliberately exposes no options parameter** on `AddCmsHealthChecks()`, so adding a database check later is a visible code change rather than configuration drift — keeping D-21 (liveness only) enforced by shape. +- **Step 5 embeds the placeholder page as an assembly resource** rather than placing it in `wwwroot/web/`, because that directory is owned and overwritten by a website workspace. +- **Step 10 modifies two existing test files** for the middleware constructor change, and adds cases asserting that a forged Owner token grants no bypass while a valid one still does. + +### U2 plan — 11 steps +5 files created, 6 modified, 1 EF migration. Verified `Microsoft.AspNetCore.DataProtection.EntityFrameworkCore` **10.0.9** exists on nuget.org and matches the existing 10.0.x line. +- **Step 5 removes `AddDataProtection()` from both modules** — the § 5.1 conflict. Recorded in the plan with its rationale, because the removal looks like a regression to a reviewer who does not know the ordering issue. +- **Step 8's first assertion is that the persistent key store survives module registration**, explicitly flagged as the highest-value test in the unit: a test that merely asserts "Data Protection is registered" passes in both the broken and fixed cases. +- A **Risk Notes for the Executor** section lists five ways this unit could be implemented in a way that looks correct while remaining ineffective, each paired with the step that prevents it. + +--- + +## Code Generation — Plans Approved (U1 and U2) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Approve Plan" + +**AI Response**: "Both code generation plans approved. Proceeded to Part 2 (Generation)." + +**Context**: CONSTRUCTION — Code Generation Part 1 approved for U1 and U2. + +--- + +## Code Generation — Complete (U1 and U2, Round 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — plan execution)" + +**AI Response**: "Executed both plans in order. 16 files created, 13 modified, 1 EF migration generated. Build clean and all tests green on the first run; nothing needed fixing and retrying. Recorded one plan deviation." + +**Context**: CONSTRUCTION — Code Generation, Part 2 Steps 10–14, for U1 and U2. + +### Verification (Step 13.5) +- `dotnet build SlpModularCms.sln -c Release` — **0 errors**, 70 warnings (all pre-existing NU1903/NU1510; the two vulnerable packages are U5's scope) +- `dotnet test SlpModularCms.sln -c Release` — **253 passed, 0 failed, 0 skipped**. Baseline was 219, so 34 tests added. Per project: Core 83 (was 54), Availability 82 (was 78), Identity 37 (unchanged), Master 51 (was 50) +- `dotnet ef migrations add AddDataProtectionKeys` — generated `20260727203036_AddDataProtectionKeys`; inspected and confirmed **purely additive** (creates one table, drops nothing), so rollback by redeploying an earlier release stays safe per BR-U2-16 +- **Embedded resource name verified against the compiled assembly manifest** — `SlpModularCms.Api.Extensions.WebsitePlaceholder.html`. A wrong name would have failed *silently*, falling back to a minimal inline HTML string, so this was checked rather than assumed + +No build or test failure occurred during generation. + +### Implementation decisions worth recording +- **The forged-token fix is proven against the real validator, not only a substitute.** The middleware's own tests substitute `IAdminTokenValidator`, which is correct unit-testing practice — but a substitute keeps passing even if the middleware were later rewired back to unvalidated token parsing. A nested `WithRealValidator` test class therefore wires the middleware to the actual `AdminTokenValidator` and asserts both halves: a forged unsigned Owner token is rejected, **and** a genuine Owner token still bypasses (an administrator must always be able to reach a disabled instance). +- **U2's tests assert resulting configuration, not registration.** A test asserting "Data Protection is registered" passes in both the broken and fixed cases because `IDataProtector` resolves either way. The tests instead inspect `KeyManagementOptions.XmlRepository` (must be the EF repository, not the filesystem default) and `DataProtectionOptions.ApplicationDiscriminator` (must be the fixed constant), plus a round-trip proving a value encrypted before a deploy is readable after one. +- **The module-level "does not override" tests live in each module's own test project**, because `Core.Tests` does not reference the modules. Each registers the host's Data Protection first and the module second — the real ordering. +- **`SqlException` is produced genuinely rather than faked.** It has no public constructor, so the test provokes a real one against an unreachable host with a one-second timeout, exercising the classifier against the exact type it will meet in production. +- **Both `AddDataProtection()` removal sites carry an explanatory comment**, because deleting them looks like a regression to anyone unaware of the ordering issue. + +### DEVIATION FROM PLAN +**U1 plan Step 11 (`StaticContentTests`) was not implemented as written.** The plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in `SlpModularCms.Api`, which `Core.Tests` does not reference and must not. `SlpModularCms.Api` has no test project, by the same deliberate convention that gives `SlpModularCms.Api.Slave` none — the Clients solution folder holds deployables, not tested libraries. Creating one would have been a structural change outside this unit's scope. + +The behaviour the step was meant to cover is largely ASP.NET Core's own static-file behaviour rather than this project's logic. The genuinely project-specific parts are **carried to the phase-level Build and Test stage**, where both hosts are started: `/admin` trailing-slash redirect, 404-vs-HTML for missing assets under either mount, SPA fallback resolution for both mounts, placeholder substitution when the website index is absent, and `/health` answering while the instance is availability-disabled. + +Recorded in the plan file (Step 11 marked `[~]` with the reason) and in `construction/u1-hosting-serving/code/generation-summary.md`. + +### Also carried to phase-level Build and Test +`MigrateCoreDatabase()` against a real SQL Server, and both hosts actually starting. Neither is verifiable without a database. + +### Behaviour note recorded for Operations +Static mounts are resolved **at startup**: a directory created after the process started is not served until the next restart. Correct for the intended deployment model — the atomic release switch links `wwwroot/web/` into place before the process starts — but it means dropping a website into a running instance requires a restart. + +### Security Compliance (Security Baseline extension — enabled, blocking) +- **SECURITY-08 — improved and now verified by test.** The forged-token bypass is closed; `AdminTokenValidatorTests` covers forged-unsigned, wrong-key, expired, wrong-issuer, non-admin, malformed and absent tokens. +- **SECURITY-13 — improved.** The key ring now survives redeploys, asserted by a round-trip test. +- **SECURITY-03 — compliant.** Migration failure logging carries diagnostic context but no connection string or credentials. +- **SECURITY-15 — compliant.** The gate fails closed; startup fails closed. +- **SECURITY-01 — partially compliant, DEV-05** (keys unencrypted at rest, compensating controls documented). + +**No blocking security findings.** + +--- + +## Code Generation — Approved (U1 and U2, Round 1) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "Continue to Next Stage" + +**AI Response**: "Round 1 code approved, including the U1 Step 11 deviation and the items carried to the phase-level Build and Test stage. Performed the mandatory phase transition check: updated this feature's status in `aidlc-docs/active-features.md` from 🔵 Inception to 🟢 Construction, and broadened its Affected Components to include Core and Modules. Proceeded to Round 2 (U3 Security Headers & CSP + U4 Observability), starting with Functional Design for both units." + +**Context**: CONSTRUCTION — Code Generation approved for U1 and U2. Phase transition applied. + +--- + +## Functional Design — Plans Created for U3 and U4 (Round 2) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "(no user input — automated planning step)" + +**AI Response**: "Created functional design plans and dedicated question files for both Round 2 units. Each plan states an explicit scope boundary against the NFR Design stage that follows for these two units, so behaviour questions and pattern questions are not duplicated." + +**Context**: CONSTRUCTION — Functional Design, Steps 1–5, for U3 and U4 together (Round 2 grouping per Q4 = B). + +### Artifacts created +- `construction/plans/u3-security-headers-functional-design-plan.md` — 6 steps +- `construction/plans/u3-security-headers-fd-questions.md` — 5 questions +- `construction/plans/u4-observability-functional-design-plan.md` — 7 steps +- `construction/plans/u4-observability-fd-questions.md` — 5 questions + +### U3 question areas +1. **A genuinely strict CSP would break the admin SPA.** Radix UI components position dialogs and dropdowns using inline `style` attributes, which `style-src 'self'` blocks without `'unsafe-inline'`. The failure is silent in the console sense — misplaced or invisible menus, visible only in a browser and only where the user clicks. Options offered: documented `'unsafe-inline'` for styles while keeping `script-src` strict, nonces/hashes (impractical because Radix generates styles per interaction), or report-only first. +2. What "relaxed" concretely permits for the public website, given the same process also serves the admin UI and API. +3. Whether headers apply in Development — flagged that HSTS on `localhost` is remembered per host by browsers and can interfere with unrelated local projects, while an active CSP locally is genuinely useful. +4. `X-Frame-Options: DENY` as specified in FR-18 also applies to the public website, so a customer embedding their own page in an iframe would break. Offered per-path scoping. +5. Behaviour when the Umami/Sentry origin lists are empty — including the case where a DSN or website ID is configured but the matching CSP origin is not, which would silently block monitoring traffic. + +### U4 question areas +1. **Sentry transport: tunnel versus direct.** The reference project tunnels through nginx because ad blockers return `ERR_BLOCKED_BY_CLIENT` for Sentry domains — meaning errors are lost precisely for ad-blocker users. Here nginx is unavailable (NFR-01), so a tunnel would have to be an app endpoint. Noted the side benefit: a tunnel keeps traffic same-origin, so U3's CSP needs no external Sentry origin. +2. **`SendDefaultPii`** — flagged that enabling it sends request headers **including cookies**, and this application carries the `refreshToken` in a cookie. SECURITY-03 forbids logging secrets and PII. +3. Production log level — noted that the current `Warning` baseline means a normal production run logs almost nothing, including startup, module discovery and migration outcome, which are the three most useful things after a deploy. +4. Which security events to emit for alerting (multi-select), including the availability-gate rejection that only became meaningful after the FR-24 fix. +5. Umami on the admin SPA — always, or respecting `Do Not Track`. + +--- + +## Functional Design — U3 Answers Received, U4 Answers Missing (Round 2) + +**Timestamp**: 2026-07-27T00:00:00Z + +**User Input**: "klaar, antwoorden staan in het bestand" — U3 answers: Q1 = B, Q2 = B, Q3 = A, Q4 = A, Q5 = C. **U4: all five `[Answer]:` tags are empty.** + +**AI Response**: "Cannot proceed to artifact generation. Two blockers reported: (1) `u4-observability-fd-questions.md` has no answers filled in at all; (2) U3 Q1 = B is technically unworkable and I described that option too mildly in the first place — correction and a follow-up question added to the U3 file, along with a confirmation question about the SECURITY-04 consequence of Q2 = B." + +**Context**: CONSTRUCTION — Functional Design, Step 6 (answer analysis) for U3 and U4. Gate not passed. + +### Blocker 1 — U4 unanswered +All five questions in `construction/plans/u4-observability-fd-questions.md` still have empty `[Answer]:` tags. Per the workflow's answer-collection rule, generation cannot start until they are completed. + +### Blocker 2 — U3 Q1 = B is not implementable, and my option description was at fault +The user chose "nonces or hashes for styles". I had described this as "theoretically neater but impractical because Radix generates styles per interaction", which understated the problem: **CSP nonces apply only to `