Files
slp-modular-cms/aidlc-docs/_shared/reverse-engineering/api-documentation.md
T
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

258 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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; `RefreshToken`s; `Invitation`s.
### `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 }`