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/sharedand theimplementsclause fails to compile. - The decorators stay server-side. They are not in
libs/core/shared, so@nestjs/swaggerandclass-validatornever 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/loginreturns an access token and sets a refresh cookie. Send the token asAuthorization: Bearer <token>; refresh it withPOST /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 theX-Organization-Idheader names one (<id>) or several (<id>,<id>, at most 50). A create, and a route that works on one organization, needs exactly one: 400ORGANIZATION_REQUIREDwithout. Anyone else sending the header: 403ORGANIZATION_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) andX-Filter-Member(records of those owners), from anyone, on// @ownedand// @unitmodels; comma-separated uuids, at most 50 each (400VALIDATION_FAILEDotherwise); 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,pageSizein 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[]andmember[]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 withorganization[]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 (400VALIDATION_FAILEDotherwise); 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 415UNSUPPORTED_MEDIA_TYPE); the helpdesk upload ismultipart/form-data. - Errors share one body with a stable code: Error codes.
- Rate limits: the public auth routes allow
THROTTLE_LIMITrequests per IP per minute (default 5); some routes add per-user limits (verification resend, password reset, 2FA attempts, helpdesk tickets). Over the limit: 429TOO_MANY_REQUESTS.
Request validation
A global ValidationPipe runs with whitelist and forbidNonWhitelisted, so
the published schema is also the enforced one:
| Request | Response |
|---|---|
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).
| Route | Permission | Reaches |
|---|---|---|
GET /api/todos | Todos.Read | your own todos, also for an admin; each row names its organization (organizationId, organizationName); a platform admin’s span every organization |
GET /api/todos/all | Todos.ReadAny | every 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/:id | Todos.ReadOne | your own; any in reach with Todos.ReadAny |
POST /api/todos | Todos.Create | always 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/:id | Todos.Update | your own; any in reach with Todos.UpdateAny. Body { title?, completed? } |
DELETE /api/todos/:id | Todos.Delete | your 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.