API Reference
Cpnucleo exposes three API services: the WebApi (REST), the IdentityApi (authentication), and the GrpcServer (gRPC command handling).
WebApi – REST Endpoints
The WebApi uses FastEndpoints to define REST endpoints with Swagger/OpenAPI documentation. Each entity has 5 standard CRUD endpoints plus a REST-only restore endpoint; the signed-in user’s own account has three self-service endpoints.
Base URL
- Learning lab:
http://localhost:5100 - Legacy load-balanced development stack:
http://localhost:9999
Swagger
Available at /swagger; UseSwaggerGen() is currently enabled in all environments. Request/response schemas are generated from the endpoint models. Each operation carries one tag, a readable id (the feature folder, such as UpdateProject), the flat query keys list endpoints accept (only the relation and date filters that resource supports), and every error status it can return with the shared envelope. The committed snapshots in docs/openapi/ (webapi.v1.json, identityapi.v1.json) are the reviewed contract: OpenApiSnapshotTests starts each host in its own process and fails when the served document differs. After an intended change, run the integration tests with UPDATE_OPENAPI_SNAPSHOTS=1 and commit the updated files.
Endpoint Pattern
Every entity follows this consistent pattern:
| Method | Route | Description |
|---|---|---|
POST |
/api/{entity} |
Create a new record from a JSON body |
GET |
/api/{entity}?id={uuid} |
Get a single record by query-string ID |
GET |
/api/{entities} |
List records (paginated; plural route such as /api/projects) |
PATCH |
/api/{entity} |
Update from a JSON body containing id and the required resource fields |
DELETE |
/api/{entity} |
Soft-delete IDs supplied as a JSON body: {"ids":["uuid"]} |
POST |
/api/{entity}/restore |
Undo the soft delete of IDs supplied as a JSON body: {"ids":["uuid"]} (REST only) |
These routes do not contain an /{id} path segment. PATCH uses each resource’s request DTO, not JSON Patch operations. Include Authorization: Bearer <token> on CRUD requests.
Authorization
The same rules apply to REST and gRPC and to every persistence style:
| Resources | Read | Create, update, remove |
|---|---|---|
| Organizations, Workflows, AssignmentTypes, Impediments (catalog) | Any authenticated user | Administrators |
| Users | Administrators | Administrators |
| Projects | Members of the project | Members; any authenticated user may create one and becomes a member |
| Assignments, UserProjects | Members of the row’s project | Members of the project (both the old and the new project when moving a row) |
| Appointments, AssignmentImpediments, UserAssignments | Members of the assignment’s project | Members; non-administrators may only record or change their own appointments |
Administrators (cpnucleo:admin claim backed by CPNUCLEO_ADMIN_LOGINS; when that list is empty, the seeded demo@cpnucleo.local account is the administrator) see and change everything. Rows the caller can’t see behave like missing rows (404 on GET, excluded from lists); writes the caller isn’t allowed to make return 403 (gRPC PermissionDenied).
Removal
DELETE bodies contain 1–100 ids; duplicates are ignored. Every resource removes its batch atomically: if any id is missing or invisible, nothing changes and the response is 404. A record that still has active dependent data (an organization with projects, a project with assignments, an assignment with appointments, …) returns 409 with the dependent kind in the message. Project and user memberships and user assignments are removed together with their project, assignment or user.
Restore (undo a removal)
POST /api/{entity}/restore with {"ids":["uuid"]} undoes soft deletes for all eleven resources (for example /api/organization/restore, /api/assignmentImpediment/restore). It is REST-only; gRPC has no restore command. The body follows the removal rules: 1–100 distinct ids, applied atomically with the persistence style of the resource’s removal. A restore sets Active = true and clears DeletedAt; nothing else changes, including updatedAt, so a project’s expectedVersion stays valid.
| Status | When |
|---|---|
200 {"success":true} |
Every id was a removed row the caller may see and change |
| 400 | Invalid body (errors.ids), or a restored row references a parent that is still removed (errors.<column>, for example errors.organizationId); restore the parent first |
| 403 | Catalog data or users restored by a non-administrator (same rule as removal) |
| 404 | Any id is unknown, still active, or not visible to the caller; nothing is restored |
| 409 | The restored user’s login is now used by another active account |
Access is checked exactly as for removal (members restore their project data, non-administrators only their own appointments). Restoring a user, project or assignment also restores the membership links (UserProjects, UserAssignments) that were removed together with it, as long as their other parent is active; links removed on their own stay removed. A member who removed a project can therefore restore it and keeps access to it.
Self-service account
Any signed-in user (including a service client, which acts as its service account) manages their own account; the account always comes from the token’s sub, never from the request body.
| Method | Route | Description |
|---|---|---|
GET |
/api/me |
{ "id", "name", "login", "createdAt", "updatedAt" }; credentials are never returned |
PATCH |
/api/me |
Body { "name" }: changes only the display name (domain name rules, 400 errors.name). Returns { "success": true } |
POST |
/api/me/password |
Body { "currentPassword", "newPassword" }. Returns { "success": true } |
A wrong current password is 400 with errors.currentPassword ("The current password is incorrect."); the new password follows the same policy as user administration (errors.newPassword) and must differ from the current one. Five wrong current passwords for one account within 15 minutes lock password changes for that account for 15 minutes: 429 with Retry-After and the error envelope, even for the right password. At most four password changes are verified at once. A successful change replaces the Argon2id hash, so the security stamp changes: every existing access token, refresh token and identity cookie of the account stops working (within the 30-second session cache) and the client signs in again with the new password. A token whose subject has no active account is rejected with 401 by session validation; without a subject the API answers 401 as well.
Error responses
Every non-2xx JSON response from WebApi and IdentityApi uses one envelope:
{
"statusCode": 400,
"message": "The request is invalid.",
"errors": { "endDate": ["End date must be on or after start date."] }
}
Validation failures are built by the same envelope helper as every other error (application/json, the shared default message). A validator-required field missing from a JSON body is reported under its own key, never as a serializer error; optional fields stay optional (an omitted create id is generated by the server). Error responses keep the security headers. Conditional requests (ETag / If-None-Match) are validated per caller: the ETag includes the caller’s identity, so one account’s cached list is never revalidated for another. Live listings revalidate the caller’s session before every snapshot and close when it is no longer valid.
errors is present for validation and domain-rule failures; keys are camelCase request property names, and generalErrors holds messages not tied to one field. Statuses: 400 validation/domain rule or a reference to a missing or removed record, 401 missing/invalid/revoked token, 403 not allowed (for example changing catalog data, someone else’s appointment, or moving a row into a project you don’t belong to), 404 not found or not visible (also for writes to rows you can’t see, on every persistence path), 409 duplicate id or login, stale project version, active dependents, or a concurrent change to the same rows (retry), 429 rate limited (with Retry-After), 500 unexpected error (details are only logged). gRPC maps the same cases to InvalidArgument, Unauthenticated, PermissionDenied, AlreadyExists and FailedPrecondition, or to Success=false results for missing rows and stale versions.
Dates, search and sorting
Timestamps are stored as UTC. Values with an offset are converted; values without one (for example "2064-06-09") are treated as UTC. search matches literally: %, _ and \ are not wildcards. sortColumn accepts persisted column names case-insensitively (credential columns are never sort keys) and falls back to Id; sortOrder is ASC or DESC.
Available Entities
| Entity | Singular route | List route |
|---|---|---|
| Appointment | /api/appointment |
/api/appointments |
| Assignment | /api/assignment |
/api/assignments |
| AssignmentImpediment | /api/assignmentImpediment |
/api/assignmentImpediments |
| AssignmentType | /api/assignmentType |
/api/assignmentTypes |
| Impediment | /api/impediment |
/api/impediments |
| Organization | /api/organization |
/api/organizations |
| Project | /api/project |
/api/projects |
| User | /api/user |
/api/users |
| UserAssignment | /api/userAssignment |
/api/userAssignments |
| UserProject | /api/userProject |
/api/userProjects |
| Workflow | /api/workflow |
/api/workflows |
Example: Create Appointment
Request: replace the example assignmentId and userId with existing related IDs. A supplied nonempty id is preserved; omitting it lets the entity factory generate one.
POST /api/appointment
Authorization: Bearer <token>
Content-Type: application/json
{
"id": "67d29a03-9200-4d6e-9030-009f4b060ce9",
"description": "Sprint planning meeting",
"keepDate": "2026-09-18T10:00:00Z",
"amountHours": 2,
"assignmentId": "35f1a233-e070-4205-909d-0eaabf89aec4",
"userId": "35b9c5c1-6abf-4d50-aee8-00abe2f09560"
}
Response (200 OK, abbreviated):
{
"appointment": {
"id": "...",
"description": "Sprint planning meeting",
"keepDate": "2026-09-18T10:00:00Z",
"amountHours": 2,
"assignmentId": "...",
"userId": "...",
"createdAt": "...",
"active": true
}
}
Data Access
REST deliberately mixes persistence examples. Many writes use EF Core through IApplicationDbContext; most reads use generic Dapper through IUnitOfWork. Organizations use generic Dapper, projects use IProjectRepository, and project creation shares an Application handler with gRPC. Impediments remain an EF-backed CRUD example. Inspect the endpoint constructor for the specific dependency.
WebClient response expectations
The WebClient CRUD screens consume the REST endpoints through src/WebClient/src/lib/api/webapi-client.ts and normalize the response shapes used by the API:
- list arrays, paginated objects, and
{ result: ... }list envelopes - raw singular items with an
id { result: item }singular envelopes- resource-key singular envelopes such as
{ organization: { ... } }
Singular normalization is used to reload a project’s current version after an edit conflict. List requests use flat query keys only (pageNumber, pageSize, search, ids) plus sortColumn=CreatedAt&sortOrder=ASC. Missing relation labels use batched list requests with comma-separated ids (at most 100 per request), then merge into the existing cache. Selected relations keep their cached labels across search pages. Listings open the SSE stream directly and use its first event as the initial snapshot. See WebClient CRUD for error, authorization and reconnect handling.
Rate Limiting
- 300 requests per minute per IP address (a dashboard and CRUD forms issue several requests per screen);
/healthzand/readyzare exempt - Fixed-window partitioning
- Queue limit: 20 additional requests
- Returns
429 Too Many Requestswith the error envelope and aRetry-Aftervalue derived from the limiter lease (60 seconds if metadata is unavailable); CORS exposesRetry-Afterto the browser client
The partition key is HttpContext.Connection.RemoteIpAddress; this is per process, not a distributed quota. Production sets ASPNETCORE_FORWARDEDHEADERS_ENABLED=true on the API containers so the address is the client behind Traefik and NGINX.
IdentityApi – Authentication
Base URL
http://localhost:5200
Swagger
Available at /swagger, currently enabled in all environments.
OpenID Connect provider
IdentityApi is an OpenID Connect provider built on Open.IdentityServer (Apache 2.0, the continuation of IdentityServer4). The protocol endpoints are listed in /.well-known/openid-configuration:
| Endpoint | Route | Use |
|---|---|---|
| Discovery | /.well-known/openid-configuration |
Issuer, endpoints, grant types |
| Signing keys (JWKS) | /.well-known/openid-configuration/jwks |
Public RS256 keys the API hosts validate with |
| Authorize | /connect/authorize |
Authorization code flow with PKCE (S256) |
| Token | /connect/token |
authorization_code, refresh_token, client_credentials |
| Revocation | /connect/revocation |
Revokes a refresh token (RFC 7009) |
| Introspection | /connect/introspect |
Token introspection |
| End session | /connect/endsession |
Signs out of the identity session |
| User info | /connect/userinfo |
Profile claims |
The device flow is disabled. The implicit grant appears in discovery because the framework supports it, but no client may use it.
Clients
| Client | Flow | Notes |
|---|---|---|
cpnucleo-webclient |
Authorization code + PKCE, no secret | Scopes openid profile cpnucleo.api offline_access; redirect {origin}/signin-callback/ and post-logout {origin}/login/ for each Cors__AllowedOrigins__N |
Identity__ServiceClients__{id} |
Client credentials | Optional. Secret (32+ characters), Login (the service account) and Scopes (default cpnucleo.api cpnucleo.grpc) |
A service client’s token carries its service account’s subject, login, admin claim and security stamp, so the API hosts apply the same rules as for people: deactivating the account or changing its credentials revokes the client. A client whose account isn’t active gets no token.
Sign-in and sign-out steps
The sign-in page is the WebClient’s Astro page; IdentityApi only adds four anonymous endpoints:
| Method | Route | Description |
|---|---|---|
GET |
/api/account/login-page?authRequest= |
The server’s login URL. Re-validates the pending authorization request and redirects to {origin}/login/?authRequest=<absolute request URL> on the WebClient origin of its redirect_uri, when that origin is one of Cors__AllowedOrigins__N. Any other origin gets the first configured origin’s page (or Identity__LoginPageUrl when set); a missing or invalid request goes there with error=request |
POST |
/api/account/login |
Form post (login, password, authRequest) from the WebClient sign-in page. Redirects to the pending authorization request, or back to the sign-in page with error=invalid, error=locked&retryAfter=N or error=request |
GET |
/api/account/logout?logoutId= |
The end-session endpoint’s logout step: ends the server-side session, removes the identity cookie, redirects to the post-logout URL |
GET |
/api/account/error?errorId= |
{ "error", "errorDescription" } for an authorization request the server rejected |
The sign-in post must come from a WebClient origin (Origin, or Referer); others get 403 with the error envelope, and sign-in errors return to the sign-in page of the origin that posted. It only returns to a pending authorization request on this host. Unknown, ambiguous (legacy duplicates) and wrong credentials take the same Argon2id time and redirect identically. Five failures for one login within 15 minutes lock it for 15 minutes regardless of client address; at most four password verifications run at once. Login is limited to 256 characters and Password to 128.
Tokens
| Parameter | Value |
|---|---|
| Issuer | https://identity-cpnucleo.jonathanperis.tech (Jwt__Issuer) |
| Audiences | WebApi: https://api-cpnucleo.jonathanperis.tech (scope cpnucleo.api); GrpcServer: https://grpc-cpnucleo.jonathanperis.tech (scope cpnucleo.grpc) |
| Access token | RS256 JWT, typ: at+jwt, 30 minutes |
| Refresh token | One-time use; each refresh returns a successor. Never valid past eight hours from sign-in |
| Identity cookie | Absolute eight hours, not sliding, SameSite=Lax |
Access tokens carry sub, sid (the sign-in session), cpnucleo:login, cpnucleo:security_stamp and, for administrators, cpnucleo:admin. The claims are recomputed from the account at every issuance, refreshes included.
Signing keys
IdentityApi signs with a rotating RSA key ring stored in IdentitySigningKeys. Each key signs for 90 days; its successor is published one day earlier, and a replaced key stays published for seven more days. Private keys are encrypted at rest with AES-256-GCM under a key derived from Jwt__SigningKey (or Identity__KeyEncryptionSecret). Data Protection keys (identity cookies) live encrypted in IdentityDataProtectionKeys, so restarts don’t sign anyone out. Jwt__SigningPrivateKey (PEM) pins one externally managed key instead.
Validation in the API hosts
WebApi and GrpcServer read the keys from the discovery document (Jwt__MetadataAddress, defaulting to the issuer’s public address) and refresh them when a token names an unknown kid. They pin RS256, at+jwt, the issuer, their own audience and the lifetime. Raw sub is retained (MapInboundClaims = false).
After signature validation they confirm, with a 30-second cache, that the account is active, the security stamp still matches, an admin claim is still listed in CPNUCLEO_ADMIN_LOGINS, and the token’s sign-in session (sid) hasn’t ended. A password change, deactivation, admin removal, sign-out or refresh-token replay therefore takes effect within 30 seconds.
Sessions and revocation
| Event | Effect |
|---|---|
| Sign-out (end session) | Session ended, its refresh tokens deleted, identity cookie removed, its access tokens rejected by the APIs |
| Refresh token replayed | Treated as theft: the whole session ends as above |
| Revocation endpoint | The given refresh token stops working |
Password or login change (administrator or POST /api/me/password) |
The identity cookie and refresh tokens stop working (security stamp); access tokens are rejected |
| Eight hours after sign-in | Refresh tokens and the identity cookie expire |
Rate Limiting
- 60 requests per minute per IP address, queue limit 5. A sign-in takes four requests and every tab refreshes on its own.
- Brute force is bounded per login by the lockout and the sign-in concurrency cap described above.
GrpcServer – Remote Command Handling
The GrpcServer uses FastEndpoints.Messaging.Remote to handle commands over HTTP/2 gRPC transport.
Ports
- gRPC transport:
http://localhost:5300(HTTP/2) - Health check:
http://localhost:5301/healthz(HTTP/1.1)
Command Pattern
Each operation is a command/result pair defined in GrpcServer.Contracts:
Command -> Handler -> Result
Available Commands (per entity)
Each of the 11 entities has these 5 commands:
| Command | Description |
|---|---|
Create{Entity}Command |
Create a new record |
Get{Entity}ByIdCommand |
Retrieve by ID |
List{Entity}sCommand |
List with pagination |
Remove{Entity}Command |
Soft delete |
Update{Entity}Command |
Update fields |
Total: 55 registered command handlers. Restore (POST /api/{entity}/restore) and the self-service account endpoints are REST-only; list commands accept the same relation and date filters as REST.
Data Access
gRPC persistence uses Dapper. Most handlers use IUnitOfWork with explicit transaction operations; project creation delegates to the shared Application handler and its IProjectCreateStore port. The transport uses typed .NET commands/results rather than a hand-maintained public .proto API.
Handler Registration
Handlers are registered in Program.cs:
app.MapHandlers(h =>
{
h.Register<CreateAppointmentCommand, CreateAppointmentHandler, CreateAppointmentResult>();
h.Register<GetAppointmentByIdCommand, GetAppointmentByIdHandler, GetAppointmentByIdResult>();
// ... 53 more handlers
});
Health Checks
All three APIs expose separate liveness and readiness endpoints:
| Service | URL | Protocol |
|---|---|---|
| WebApi | /healthz, /readyz |
HTTP; lab port 5100 |
| IdentityApi | /healthz, /readyz |
HTTP; lab port 5200 |
| GrpcServer | /healthz, /readyz |
HTTP/1.1 diagnostics; lab port 5301 |
Root Endpoint
The API hosts map GET / to "Hello World!"; the gRPC host’s fallback authorization policy applies to that root route. The WebClient root serves the application HTML.
Authentication Flow
JWT authentication is enforced by WebApi and GrpcServer. User administration additionally requires the configured administrator claim on both transports:
- The WebClient starts the authorization code flow with PKCE at IdentityApi’s
/connect/authorize. - Without a session, IdentityApi sends the browser to the WebClient sign-in page, which posts the credentials to
/api/account/login. - IdentityApi issues an authorization code to
{origin}/signin-callback/; the WebClient redeems it at/connect/tokenwith the PKCE verifier and gets an access token, a one-time refresh token and an identity token. - The access token goes in
Authorization: Bearer {token}for WebApi; service clients useclient_credentialsfor WebApi or GrpcServer. - The APIs validate signature (JWKS), issuer, audience, type and lifetime, then the account, stamp, admin claim and session.
Refreshes use the refresh_token grant; signing out uses /connect/revocation and /connect/endsession.
Query and update contracts
List query fields are flat: pageNumber, pageSize (1–100), sortColumn, sortOrder, search (up to 128 characters), and ids (up to 100 comma-separated UUIDs). For example: /api/projects?pageSize=25&search=school. FastEndpoints binds these scalar fields into the request’s Pagination object; dotted pagination.* keys are not the canonical HTTP contract. Out-of-bound values are a 400 with the offending field (errors.pageSize, errors.ids, …). gRPC list commands apply the same rules and answer InvalidArgument with the same message, including for hand-crafted messages that bypass the typed contracts or omit Pagination.
Lists also accept optional relation and date filters, combined with AND on top of visibility, search and ids (they only ever narrow what the caller may see):
| Field | Value | Applies to |
|---|---|---|
organizationId |
One UUID | Projects |
projectId |
One UUID | Assignments, UserProjects |
assignmentId |
One UUID | Appointments, AssignmentImpediments, UserAssignments |
userId |
One UUID | Assignments, Appointments, UserAssignments, UserProjects |
workflowId |
One UUID | Assignments |
dateFrom, dateTo |
ISO-8601 date or date-time (2031-01-20, 2031-01-20T10:00:00Z, 2031-01-20T10:00:00%2B02:00); values without an offset are UTC |
Appointments: keepDate in [dateFrom, dateTo). Assignments: the startDate..endDate period overlaps the range (endDate >= dateFrom and startDate < dateTo) |
Each bound is optional. A malformed UUID is 400 errors.<field> ("ProjectId must be a UUID."), a malformed date "DateFrom must be an ISO-8601 date and time.", and dateFrom after dateTo is reported on errors.dateTo. A filter the resource does not support is rejected rather than ignored: 400 with the field ("Projects cannot be filtered by userId."), and gRPC InvalidArgument with the same message. Live listings (Accept: text/event-stream) apply the same filters to every snapshot and reject the same requests with 400 before the stream starts. Encode + in offsets as %2B.
Project PATCH requests may include expectedVersion, using the last observed updatedAt or initial createdAt. A stale value returns HTTP 409. The corresponding gRPC command accepts ExpectedVersion and reports a failed result on a conflict. Omitting the field preserves legacy last-write-wins behavior.
All normal removal paths soft-delete, and POST /api/{entity}/restore undoes them. Project batches are atomic. The database rejects conflicting normalized active logins on new/changed accounts; authentication rejects ambiguous legacy logins rather than choosing an arbitrary account.
/healthz is liveness only. /readyz checks database/schema availability. SSE listings refresh periodically so writes through other instances/transports converge within a refresh cycle. Streams are sent with FastEndpoints’ event-stream sender: each listing event carries an id, proxy buffering is disabled (X-Accel-Buffering: no), and streams end when the token expires, the client disconnects or the host shuts down.