Files
slp-modular-cms/aidlc-docs/_shared/reverse-engineering/architecture.md
T
SluijsensandClaude Opus 5 8568ca43c6 Plans the Gitea deployment feature and refreshes the codebase analysis
Adds the AI-DLC inception record for deploying the CMS as a single .NET
application on hosting where no server configuration is possible.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-27 23:59:30 +02:00

236 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# System Architecture
## System Overview
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 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
visitor["Public visitor"]
adminuser["Admin user (browser)"]
subgraph host["SlpModularCms.Api — single host process"]
static["Static files + SPA fallbacks<br/>wwwroot/ and wwwroot/admin/"]
pipeline["Middleware pipeline<br/>exception handler, rate limiter,<br/>HTTPS redirect, CORS, availability gate, auth"]
orchestrator["ModuleOrchestrator<br/>assembly discovery"]
core["SlpModularCms.Core<br/>identity, authz, module contract,<br/>routing convention, error handling"]
modidentity["Modules.Identity<br/>auth, setup, invitations, users"]
modavail["Modules.Availability<br/>availability gate, master registration"]
modmaster["Modules.Master<br/>instance registry, status push"]
end
db[("SQL Server<br/>ApplicationDbContext<br/>AvailabilityDbContext<br/>MasterDbContext")]
slaveinst["Slave CMS instances<br/>(separate deployments)"]
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
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**: 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 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**: 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**: Decides whether this instance serves requests.
- **Responsibilities**: `AvailabilityMiddleware` (dual gate: master gate then local status, with bypass prefixes and admin-token bypass); `PersistentAvailabilityService`; `AvailabilityDbContext` holding `MasterRegistration`; `MasterController` for inbound master calls; `MasterStatusPollingBackgroundService` (pull + fail-open); Data Protectionencrypted master API key.
- **Dependencies**: Core.
- **Type**: Application module. Self-migrates at startup.
### SlpModularCms.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
box rgba(246,224,94,0.4) Client
participant B as Browser (admin SPA)
end
box rgba(99,179,237,0.4) Host
participant A as AuthController
participant S as AuthService
end
box rgba(214,188,250,0.4) Data
participant D as SQL Server
end
B->>A: POST /api/v1/auth/login
A->>S: authenticate credentials
S->>D: verify user and persist refresh token
D-->>S: ok
S-->>A: access token plus refresh token
A-->>B: 200 with access token, refresh cookie set
B->>A: POST /api/v1/auth/refresh on startup
A->>S: rotate refresh token
S->>D: revoke old and store new
D-->>S: ok
A-->>B: 200 with new access token and cookie
```
Text alternative: The SPA logs in, the host verifies credentials and stores a refresh token, returning an access token plus an httpOnly cookie; on startup the SPA silently refreshes, rotating the stored token.
### Master registers a slave and pushes status
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Operator
participant O as Owner
end
box rgba(99,179,237,0.4) Master instance
participant M as CmsInstanceController
participant K as ApiKeyProtector
participant C as SlaveApiClient
end
box rgba(154,230,180,0.4) Slave instance
participant SL as MasterController
end
O->>M: POST /api/v1/CmsInstances with slave URL
M->>K: generate and encrypt API key
K-->>M: protected key stored
M->>C: push registration
C->>SL: POST /api/v1/master/register with X-Master-Api-Key
SL-->>C: 200 registered
O->>M: PUT /api/v1/CmsInstances/{id}/status
M->>C: push new status
C->>SL: POST /api/v1/master/status
SL-->>C: 200 applied
```
Text alternative: The Owner adds a slave by URL; the Master generates and encrypts an API key, pushes the registration to the slave, and later pushes each status change synchronously.
### Availability gate evaluation
```mermaid
graph TD
req["Incoming request"]
bypass{"Bypass prefix?<br/>Availability/status, Auth/,<br/>Setup/status, master/, SlaveStatus"}
adminbp{"Owner or Administrator<br/>bearer token?"}
mgate{"Master gate<br/>available?"}
local{"Local status<br/>Available?"}
pass["Continue pipeline"]
block["503 ProblemDetails"]
req --> bypass
bypass -->|yes| pass
bypass -->|no| adminbp
adminbp -->|yes| pass
adminbp -->|no| mgate
mgate -->|no| block
mgate -->|yes| local
local -->|yes| pass
local -->|no| block
classDef start fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class req start;
class bypass,adminbp,mgate,local decision;
class pass good;
class block bad;
```
Text alternative: Requests to bypass prefixes or carrying an Owner/Administrator token always pass; otherwise the master gate is checked first and then the local availability status, and failing either returns a 503 ProblemDetails.
## Integration Points
- **External APIs**: None 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**: 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).