Headers
| Header | When | Notes |
|---|---|---|
orgid | Always | The tenant. Also accepted as a query param, a body field, or an orgid / orgId cookie. |
Authorization: Bearer <jwt> | Unless the route is public | Falls back to a token or jwt cookie. |
apiKey or x-api-key | Server-to-server | Alternative to a bearer token. Resolved to its owning user. |
x-client-authorization | Service accounts | A customer token, carried alongside a System user token. Required for System repository writes. |
domainAsOrg: true | Multi-domain hosting | Used with shared-org-id and x-client-host to resolve the tenant from a domain. |
shared-org-id | Multi-domain hosting | The shared org that owns the session. |
x-client-host | Multi-domain hosting | The visitor-facing domain to resolve. |
x-client-orgid | Multi-domain hosting | Skips domain resolution and uses this org directly. |
x-client-info | Optional | JSON client telemetry. When present with an orgid, a web_visit record is written fire-and-forget. |
impersonate_user, impersonate_org | Reserved | Read by the middleware, but the handler is currently a no-op. |
Request bodies
JSON, 4 MB maximum (express.json({ limit: '4mb' })). URL-encoded bodies share the limit.
The global ValidationPipe is configured:
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
})forbidNonWhitelisted: true means any property the DTO does not declare rejects the whole request. If you round-trip a record — GET, edit one field, POST back — strip fields the write DTO does not accept first. enableImplicitConversion does coerce "5" to 5 where the DTO says number.
Response envelopes
Single record
A BaseModel<T> — platform fields at the top level, your payload under data.
{ "pk": "…", "sk": "6512ab…", "name": "Blue Widget", "datatype": "sf_product",
"version": 3, "state": "published", "createdate": "…", "modifydate": "…",
"data": { "price": 19.99, "sku": "BW-1" } }List
A BaseModelDTO<T>.
{ "datatype": "sf_product", "total": 128, "page": 0, "pageSize": 25,
"hasNext": true, "fromCache": false,
"data": [ { "sk": "…", "data": { … } } ] }Paging and query options
DataOptions fields are accepted on list and search endpoints:
| Field | Type | Description |
|---|---|---|
page | number | Zero-based page index. |
pageSize | number | Records per page. |
lastItem | number | Cursor position, for continuation-style paging. |
sort | any | Field or sort specification. |
sortType | SortType | 1 ascending, -1 descending. |
modelState | `ModelState | ModelState[]` |
includeFields | string[] | Projection allow-list. |
excludeFields | string[] | Projection deny-list. |
maskFields | string[] | Fields returned masked rather than omitted. |
enrich | boolean | Resolve references and inline the related records. |
refresh | boolean | Bypass cache. Check fromCache on the response to see whether it mattered. |
random | boolean | Return records in random order. |
Errors
A global filter (AllExceptionsFilter, registered as APP_FILTER) normalizes what handlers throw into one envelope:
{
"statusCode": 401,
"error": "Token has expired. Refresh the access token or request a new one.",
"path": "/repository/get/customer/6512ab",
"method": "GET",
"timeStamp": "2026-08-28T10:00:00.000Z"
}Non-HTTP exceptions are categorized first — MongoDB errors, for instance, are mapped to appropriate status codes rather than surfacing as a bare 500.
Structured auth errors
Auth failures are thrown with a richer shape, carrying a stable code and sometimes a recommended action:
code | Status | Meaning | action |
|---|---|---|---|
missing_orgid | 400 | No tenant could be resolved | — |
missing_authorization_header | 401 | No bearer token and no cookie | — |
token_expired | 401 | JWT past its expiry | refresh_token |
invalid_token | 401 | Malformed or unverifiable JWT | refresh_token |
user_not_found | 401 | Token valid, but the user is not in this org | refresh_token |
auth_error | 401 | Any other authentication failure | refresh_token |
Depending on where in the pipeline a failure happens, a client may receive the structured object or the normalized { statusCode, error, path, method, timeStamp } envelope. Branch on the HTTP status first, and read code when it is present rather than requiring it.
Other common statuses
| Status | Cause |
|---|---|
400 | Validation failure, unknown body property, missing orgid, bad credentials |
403 | Blocked device; requiredRole.read denied by ContentPermissionInterceptor |
404 | Unknown org (Organization or Site Not Found), missing record, or a scanner-probe path |
429 | Rate limited |
503 | /health reporting an unhealthy dependency |
Rate limiting
Fixed window, applied before routing:
- Window: 15 minutes
- Limit:
RATE_LIMIT_MAX, default10000per IP - Headers: standard
RateLimit-*(legacyX-RateLimit-*off) - Exempt:
/monitoring/health
{ "statusCode": 429, "error": "Too Many Requests",
"message": "Too many requests from this IP, please try again later" }The client IP is derived with trust proxy set to exactly TRUST_PROXY_HOPS hops (default 2 — Cloudflare, then Traefik). A specific count rather than true is what stops a client from spoofing X-Forwarded-For to escape the limit. Change your proxy topology and you must change this value.
The 429 handler sets CORS headers itself, because Nest applies CORS during init() — after this middleware — so short-circuited responses would otherwise reach the browser without them and surface as an opaque network error.
CORS
- Development: any origin, credentials allowed.
- Production: origins from
ALLOWED_ORIGINS(comma-separated); methodsGET, POST, PUT, DELETE, PATCH, OPTIONS; credentials allowed; preflight cached 24 h.
Route aliasing
Two patterns produce multiple URLs for one handler, and both appear in the auth module:
- Array controller prefixes —
@Controller(['profile', 'user'])mounts everything under both. - Array method paths —
@Post(['/user/signin', '/signin']).
Combined, signin answers on four URLs. They are the same handler; pick one form per client.
Some handler paths also begin with /, which produces a doubled separator in naive path concatenation. The router normalizes it — /profile/whoami is correct, /profile//whoami is what you see if you build the path by hand.
Timeouts
Keep-alive is 65 s with a 66 s headers timeout — deliberately above a typical 60 s load-balancer idle timeout, so the balancer closes idle connections rather than letting them accumulate on the server.
Health and version
/healthNo auth/readinessNo auth/versionNo authAll three skip the tenant requirement. /health returns { isHealthy, services, timestamp } with 200 or 503, and sends Cache-Control: no-cache, no-store, must-revalidate.