# 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 `). 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()`. 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 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`) - **Fields**: identity fields plus `Name`, `IsActive`, `CreatedAt`. - **Relationships**: roles via Identity; `RefreshToken`s; `Invitation`s. ### `ApplicationRole` (extends `IdentityRole`) - **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 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 missing `Secret` throws at startup. - `AvailabilityOptions { CircuitBreakerSeconds, StatusCacheSeconds }` - `MasterModuleOptions { IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl }` - `MasterPollingOptions { PollIntervalSeconds, FailOpenAfterMinutes, HttpTimeoutSeconds }`