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

18 KiB
Raw Permalink Blame History

API Documentation

Conventions

  • Global prefix: every controller is served under api/v1, applied centrally by ApiPrefixConvention in Program.cs — controllers themselves declare only their own route segment. API version reporting is enabled via Asp.Versioning.Mvc.
  • Authentication: JWT bearer (Authorization: Bearer <accessToken>). Token validation uses ClockSkew.Zero, so expiry is exact.
  • Refresh token: returned as an httpOnly cookie named refreshToken, SameSite=Strict (None in Development), scoped to path /api/v1/auth. It is deliberately [JsonIgnore]d out of the response body.
  • Authorization policies: OwnerOnly, AdminOnly, UserOnly — hierarchical, so a higher role satisfies a lower requirement.
  • Errors: RFC 9457 ProblemDetails via GlobalExceptionHandler.
  • Enums: serialized as strings (JsonStringEnumConverter).
  • Rate limiting: named limiters login (fixed window, default 5/60s) and refresh (sliding window, default 20/60s, 4 segments); rejections return 429.
  • Availability gate: unless a path is on the bypass list or the caller presents an Owner/Administrator token, requests are blocked with 503 ProblemDetails when the master gate or the local status says unavailable. Bypass prefixes: /api/v1/Availability/status, /api/v1/Auth/, /api/v1/Setup/status, /api/v1/master/, /api/v1/SlaveStatus.
  • API reference UI: Scalar at /scalar and the OpenAPI document are mapped in Development only.

Non-API routes served by the same host

Path Behaviour
/ and /{*path:nonfile} Public website from wwwroot/, with SPA fallback to wwwroot/index.html. Served by static-file middleware before the availability gate is installed.
/admin and /admin/{*path:nonfile} Admin SPA from wwwroot/admin/, fallback to wwwroot/admin/index.html.
Any path with a file extension that does not exist Returns 404 — the nonfile route constraint deliberately excludes it from the SPA fallbacks.

REST APIs

Authentication — Modules.Identity/AuthController (/api/v1/auth)

Login

  • Method: POST
  • 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.

Refresh

  • Method: POST
  • 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.

Revoke

  • Method: POST
  • 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 — Modules.Identity/SetupController (/api/v1/Setup)

Get setup status

  • Method: GET
  • 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 }.

Create initial owner

  • Method: POST
  • 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)

Validate invitation

  • Method: GET
  • 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.

Complete invitation

  • Method: POST
  • 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)

Controller default policy: AdminOnly.

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<ApplicationDbContext>(). It performs CanConnectAsync by default and can optionally report pending migrations — worth enabling here, since ApplicationDbContext is never migrated automatically.

Required placement detail specific to this codebase: MapHealthChecks registers an endpoint, and middleware runs before endpoints — so AvailabilityMiddleware would return 503 for /health on any instance that is switched off, reproducing exactly the conflation this section warns against. /health must therefore be added to AvailabilityMiddleware._bypassPrefixes, alongside the master endpoints. With that in place the separation stays clean: /health reports infrastructure, availability reports product state.

Internal APIs

IModule (Core/Modules/IModule.cs)

  • Methods: string Name { get; }, string Version { get; }, void RegisterServices(IServiceCollection services), void UseModule(IApplicationBuilder app)
  • Purpose: The contract every module implements. UseModule is also where Modules.Availability and Modules.Master run Database.Migrate() for their own contexts.

ModuleOrchestrator (Core/Hosting/ModuleOrchestrator.cs)

  • Methods: IReadOnlyList<string> ModuleNames { get; }, DiscoverModules(), RegisterModuleServices(IServiceCollection), UseModules(IApplicationBuilder)
  • Behaviour: DiscoverModules globs SlpModularCms.Modules.*.dll in AppDomain.CurrentDomain.BaseDirectory and instantiates every concrete IModule. Load and instantiation failures are logged, not rethrown — a broken module degrades capability silently rather than failing startup.

IAvailabilityService (Core/Availability)

  • Methods: IsAvailableAsync(), GetStatusDetailsAsync(), and on PersistentAvailabilityService also UpdateStatusAsync(status, reason, updatedBy)
  • Returns: AvailabilityStatus / AvailabilityStatusDetails { Status, Message?, IsMasterControlled }
  • Validation: throws MasterControlledAvailabilityException when a local update is attempted while master-controlled.

IMasterAvailabilityService (Modules.Availability/Services)

  • Methods: GetMasterStatus()MasterGateStatus { IsAvailable, DisableMessage? }, plus registration and push handling.
  • Purpose: Supplies the master gate its verdict, including the fail-open decision when the Master has been unreachable beyond MasterPolling:FailOpenAfterMinutes.

ISlaveApiClient (Modules.Master/Services)

  • Purpose: Outbound HTTP to slaves (register, push status). Wrapped in a slave-resilience pipeline: 2 retries, exponential backoff with jitter, timeout from MasterModule:HttpTimeoutSeconds.

IApiKeyProtector / IMasterApiKeyProtector

  • Purpose: Encrypt and decrypt slave API keys using ASP.NET Core Data Protection. Both rely on the default file-system key ring; no persistent store is configured.

IAuthService, IInvitationService, ISetupService (Core/Identity/Services)

  • Purpose: Authentication with refresh-token rotation, invitation lifecycle, and first-Owner bootstrap respectively.

Frontend ApiClient (frontend/src/lib/api-client.ts)

  • Purpose: Single fetch wrapper for the SPA. Always sends credentials so the refresh cookie travels; holds the access token in memory only; on 401 runs a refresh-and-retry once; throws ProblemDetailsError for non-2xx and NetworkError for transport failures.
  • Base URL: VITE_API_BASE_URL, validated as an absolute URL by frontend/src/lib/config.ts.

Data Models

ApplicationUser (extends IdentityUser<Guid>)

  • Fields: identity fields plus Name, IsActive, CreatedAt.
  • Relationships: roles via Identity; RefreshTokens; Invitations.

ApplicationRole (extends IdentityRole<Guid>)

  • Fields: identity role fields. Roles in use: Owner, Administrator, User.

RefreshToken

  • Fields: token value, expiry, revocation state, owning user.
  • Validation: rotated on every refresh; the previous token is revoked.

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 Protectionencrypted; 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 }