Skip to content

API

Every product API is a REST API under /api, described by an OpenAPI document generated from the code. Use this page when you call the API from another client, generate a typed client, or put the API behind a proxy.

OpenAPI and Swagger UI

Swagger UI is mounted at http://localhost:3000/api/docs, and the OpenAPI 3 document itself at /api/docs-json - point openapi-typescript, orval, or any other generator at that URL.

Schemas come from the DTO classes (libs/core/api/*/src/lib/dto/, and your product’s), each of which implements the matching interface from @core/shared:

export class CreateTodoDto implements CreateTodoContract {
@ApiProperty({ example: 'Build something great with Nx', maxLength: 500 })
@IsString()
@IsNotEmpty()
@MaxLength(500)
title!: string;
}

Two consequences worth knowing:

  • The docs cannot drift from the contract. Rename a field in libs/core/shared and the implements clause fails to compile.
  • The decorators stay server-side. They are not in libs/core/shared, so @nestjs/swagger and class-validator never reach the browser bundle.

Nest’s Swagger CLI plugin - which infers schemas from TypeScript types and saves writing @ApiProperty - is deliberately not used: it is a tsc transformer, and this workspace builds with tsdown/oxc. The decorators are explicit instead.

Calling it

  • Authentication: POST /api/auth/login returns an access token and sets a refresh cookie. Send the token as Authorization: Bearer <token>; refresh it with POST /api/auth/refresh (the cookie travels by itself). Details: Authentication.
  • Organization: a member’s organization routes act on the session’s active organization (switch with POST /api/auth/organization). A platform admin has none: their requests reach every organization, and the X-Organization-Id header names one (<id>) or several (<id>,<id>, at most 50). A create, and a route that works on one organization, needs exactly one: 400 ORGANIZATION_REQUIRED without. Anyone else sending the header: 403 ORGANIZATION_OVERRIDE_FORBIDDEN. See The platform admin.
  • Filter headers: every organization route takes X-Filter-Unit (records of the members of those units and their sub-units, or rows of those units) and X-Filter-Member (records of those owners), from anyone, on // @owned and // @unit models; comma-separated uuids, at most 50 each (400 VALIDATION_FAILED otherwise); intersected with your reach, so a value outside it shrinks the result, never 403. Reads, updates and deletes only. See Tenant data.
  • Lists share one contract: search, sort, order, page, pageSize in the query, and { data, total, page, pageSize } in the response. Multi-value filters repeat the parameter (?unit=a&unit=b; one value is a one-element list): organization[] on the platform lists (GET /api/users, /api/audit-events, /api/helpdesk/tickets, /api/organizations), unit[] and member[] on the organization lists (GET /api/members, /api/organization/audit-events, /api/organization/helpdesk/tickets) and on three of the platform lists (GET /api/users, /api/audit-events, /api/helpdesk/tickets: unit[] keeps the people who are live members of those units or their sub-units - per organization on events and tickets, on users by account - member[] those users; they combine with organization[] by AND, and a deleted or unknown unit matches nothing: an empty page, never 400). Each value is a uuid, at most 50 per filter (400 VALIDATION_FAILED otherwise); a value outside your reach shrinks the result, never 403. See Admin pages per level.
  • JSON bodies: send application/json (the auth routes refuse anything else with 415 UNSUPPORTED_MEDIA_TYPE); the helpdesk upload is multipart/form-data.
  • Errors share one body with a stable code: Error codes.
  • Rate limits: the public auth routes allow THROTTLE_LIMIT requests per IP per minute (default 5); some routes add per-user limits (verification resend, password reset, 2FA attempts, helpdesk tickets). Over the limit: 429 TOO_MANY_REQUESTS.

Request validation

A global ValidationPipe runs with whitelist and forbidNonWhitelisted, so the published schema is also the enforced one:

RequestResponse
POST /api/todos {}400 title should not be empty
POST /api/todos {"title":"x","bogus":1}400 property bogus should not exist
PATCH /api/todos/<uuid> {"completed":"yes"}400 completed must be a boolean value

An unknown field is rejected rather than stripped: a typo’d field name should not look like a success. title is capped at 500 characters - an API-level choice, not a database limit; the constant lives in libs/keelmint/api/todos/src/lib/dto/todo-constraints.ts.

Keelmint’s todo routes

The sample domain. Todo is an // @owned model (Tenant data): a route reaches the caller’s own todos, and the matching *Any permission widens it to the todos of the members in the caller’s reach (held organization-wide: the organization; held in a unit: the members of that unit and its sub-units).

RoutePermissionReaches
GET /api/todosTodos.Readyour own todos, also for an admin; each row names its organization (organizationId, organizationName); a platform admin’s span every organization
GET /api/todos/allTodos.ReadAnyevery todo in your reach, with the owner’s userId and ownerEmail and the organization; a platform admin’s span every organization (X-Organization-Id narrows them); X-Filter-Unit / X-Filter-Member narrow further
GET /api/todos/:idTodos.ReadOneyour own; any in reach with Todos.ReadAny
POST /api/todosTodos.Createalways your own; counts toward the organization’s todos plan limit (every member’s todos); a platform admin names the organization with X-Organization-Id (without: 400 ORGANIZATION_REQUIRED)
PATCH /api/todos/:idTodos.Updateyour own; any in reach with Todos.UpdateAny. Body { title?, completed? }
DELETE /api/todos/:idTodos.Deleteyour own; any in reach with Todos.DeleteAny. Soft delete, 204

A todo out of reach is 404, like another organization’s. Bodies carry no userId or unitId: either is 400 (property ... should not exist), so the owner never changes.

Security headers and CORS

main.ts runs Helmet with its default CSP relaxed only for the inline scripts/styles Swagger UI needs - everything else Helmet sets (HSTS, X-Content-Type-Options, etc.) stays at its defaults.

CORS defaults to same-origin-only (origin: false - no Access-Control-Allow-Origin header at all), so it fails closed rather than reflecting an arbitrary Origin back with credentials: true, which would defeat CORS entirely for the refresh cookie. Set CORS_ORIGIN (comma-separated origins) once the client is hosted anywhere but behind the same proxy as the API - see apps/keelmint/api/.env.example.

TRUST_PROXY sets Express trust proxy, which decides req.ip: the IP the audit log records and the per-IP rate limit (THROTTLE_LIMIT) counts. Unset (false) uses the socket address. Behind a reverse proxy or load balancer set it, or every request looks like the proxy’s: a hop count or the proxies’ IPs/CIDRs (10.0.0.0/8, loopback). The hop count must equal the number of trusted proxies in front of the API (1 for one nginx; at most 10): a larger count trusts entries the client wrote and lets it spoof its IP. For the same reason true and /0 ranges are refused at boot. In development the Angular proxy forwards the browser’s address (xfwd: true in proxy.conf.json); set TRUST_PROXY=loopback to see it - dev only, since your own browser can then set X-Forwarded-For itself.