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
18 KiB
API Documentation
Conventions
- Global prefix: every controller is served under
api/v1, applied centrally byApiPrefixConventioninProgram.cs— controllers themselves declare only their own route segment. API version reporting is enabled viaAsp.Versioning.Mvc. - Authentication: JWT bearer (
Authorization: Bearer <accessToken>). Token validation usesClockSkew.Zero, so expiry is exact. - Refresh token: returned as an httpOnly cookie named
refreshToken,SameSite=Strict(Nonein 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
ProblemDetailsviaGlobalExceptionHandler. - Enums: serialized as strings (
JsonStringEnumConverter). - Rate limiting: named limiters
login(fixed window, default 5/60s) andrefresh(sliding window, default 20/60s, 4 segments); rejections return429. - Availability gate: unless a path is on the bypass list or the caller presents an Owner/Administrator token, requests are blocked with
503 ProblemDetailswhen 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
/scalarand 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:
200TokenResponse { accessToken, expiresAt, user { id, email, name, role, isActive } }plus arefreshTokencookie.
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
refreshTokencookie. - Response:
200TokenResponseplus a replaced cookie;401when 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, orProblemDetailson 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 ProblemDetailswhen the Master controls this instance's status (MasterControlledAvailabilityException);400if 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 byX-Master-Api-Key. On the availability bypass list. - Response:
200with the caller's status,401without 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
503while 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
NotAvailablewhen an Owner or its Master has switched it off, and a sick instance can still reportAvailable. - 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()andapp.MapHealthChecks("/health")need no package at all — both live in the shared framework. Default output is plain textHealthywith200orUnhealthywith503, which is exactly what an HTTP-probe monitor consumes.- Adding a database probe costs one package,
Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore10.0.9 (in line with the rest of the 10.0.x dependencies), and one call:.AddDbContextCheck<ApplicationDbContext>(). It performsCanConnectAsyncby default and can optionally report pending migrations — worth enabling here, sinceApplicationDbContextis 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.
UseModuleis also whereModules.AvailabilityandModules.MasterrunDatabase.Migrate()for their own contexts.
ModuleOrchestrator (Core/Hosting/ModuleOrchestrator.cs)
- Methods:
IReadOnlyList<string> ModuleNames { get; },DiscoverModules(),RegisterModuleServices(IServiceCollection),UseModules(IApplicationBuilder) - Behaviour:
DiscoverModulesglobsSlpModularCms.Modules.*.dllinAppDomain.CurrentDomain.BaseDirectoryand instantiates every concreteIModule. 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 onPersistentAvailabilityServicealsoUpdateStatusAsync(status, reason, updatedBy) - Returns:
AvailabilityStatus/AvailabilityStatusDetails { Status, Message?, IsMasterControlled } - Validation: throws
MasterControlledAvailabilityExceptionwhen 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-resiliencepipeline: 2 retries, exponential backoff with jitter, timeout fromMasterModule: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
fetchwrapper for the SPA. Always sends credentials so the refresh cookie travels; holds the access token in memory only; on401runs a refresh-and-retry once; throwsProblemDetailsErrorfor non-2xx andNetworkErrorfor transport failures. - Base URL:
VITE_API_BASE_URL, validated as an absolute URL byfrontend/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;
InvitationOrUserAlreadyExistsExceptionguards 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
PersistentAvailabilityServicebehind 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
MasterPollingsettings 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 missingSecretthrows at startup.AvailabilityOptions { CircuitBreakerSeconds, StatusCacheSeconds }MasterModuleOptions { IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl }MasterPollingOptions { PollIntervalSeconds, FailOpenAfterMinutes, HttpTimeoutSeconds }