Files
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

15 KiB
Raw Permalink Blame History

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/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

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 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).