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
15 KiB
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
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/v1prefix 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:
ApplicationDbContextand Identity entities;AuthService,InvitationService,SetupService;HierarchicalRoleHandlerand the Owner/Admin/User policies;IModule+ModuleOrchestrator;ApiPrefixConvention;GlobalExceptionHandlerand typed exceptions;IAvailabilityServicecontract;SystemControllercapability 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;AvailabilityDbContextholdingMasterRegistration;MasterControllerfor inbound master calls;MasterStatusPollingBackgroundService(pull + fail-open); Data Protection–encrypted master API key. - Dependencies: Core.
- Type: Application module. Self-migrates at startup.
SlpModularCms.Modules.Master
- Purpose: Central control point over other instances.
- Responsibilities:
MasterDbContextwithCmsInstance;CmsInstanceController(Owner-only);SlaveStatusController(anonymous, API-key authenticated pull endpoint);SlaveApiClientwith retry + timeout resilience;ApiKeyProtector(Data Protection);IntegrityCheckBackgroundServicefor periodic reconciliation. - Dependencies: Core,
Microsoft.Extensions.Http.Resilience. - Type: Application module. Self-migrates at startup.
frontend (admin SPA)
- Purpose: Admin UI, served at
/adminin 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/adminon publish.
Data Flow
Login and silent refresh
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
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
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
DbContexttypes viaConnectionStrings: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 underwwwroot/. 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.jsonbaseline with placeholder values,appsettings.Development.json, gitignoredappsettings.local.json). Production secrets are expected as environment variables using theSection__Keyconvention:ConnectionStrings__DefaultConnection,JwtSettings__Secret,JwtSettings__Issuer,JwtSettings__Audience,MasterModule__MasterUrl. - Networking:
AllowedHostsis*; CORS origins come fromCors: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:
AvailabilityDbContextandMasterDbContextcallDatabase.Migrate()in their module'sUseModule.ApplicationDbContext(Core/Identity) is never migrated automatically and requires an explicitdotnet ef database updateor a generated SQL script per environment. - Key management: Both
ApiKeyProtector(Master) andMasterApiKeyProtector(Availability) useservices.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:
- No CI/CD exists yet — this repository has no
.gitea/workflows/. dotnet publishonSlpModularCms.Apirequires Node and pnpm on the build machine: theBuildAndCopyAdminFrontendtarget runspnpm install --frozen-lockfileandpnpm buildbefore publish.- The admin SPA needs an absolute API base URL at build time.
frontend/src/lib/config.tsreadsVITE_API_BASE_URLand 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. - No health endpoint exists — there is no
MapHealthChecks,/healthor readiness/liveness route anywhere. One has to be added before uptime monitoring can be wired up. Note thatAvailabilityandSystem/capabilitiesare 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 reportNotAvailableby design, and a sick one can reportAvailable. - The public website at
/is not behind the availability gate. Static files are served beforeorchestrator.UseModules(app)installsAvailabilityMiddleware, so an existingwwwroot/index.htmlshort-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. ModuleOrchestratordiscovers modules by globbingSlpModularCms.Modules.*.dllin 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.- Data Protection has no persistent key ring, so redeploys risk invalidating stored slave API keys (already flagged in the README).