Conventions
The response envelope, error shapes, pagination semantics, and the scope parameter — the rules the generated reference does not state.
Base URL and prefix
Every application route is prefixed with api. The one exception is GET /health, excluded in the setGlobalPrefix() call and therefore served at the root.
http://localhost:8000/api/… application routes
http://localhost:8000/health liveness
http://localhost:8000/api-docs Swagger UI
The response envelope
Successful responses wrap their payload in an object with message and data:
{
"message": "Collection fetched successfully",
"data": {
"id": "CLa7Bk9x2Q",
"title": "Brand assets",
"description": "Logos and wordmarks"
}
}
Deletes return the message alone:
{ "message": "Collection deleted successfully" }
The SPA relies on this shape. api/tala.ts types it as Envelope<T> and unwraps .data for every caller:
type Envelope<T> = { data: T; message?: string };
const api = async <T>(path: string, options?: RequestInit) =>
(await fetchJSON<Envelope<T>>(`${API_BASE_URL}/api${path}`, options)).data;
Two endpoints break the pattern and return data with no message:
| Endpoint | Returns |
|---|---|
POST /api/auth/refresh-token |
{ data: { token, refreshToken } } |
GET /api/auth/user |
{ data: <user> } |
The second matters more than it looks: AuthProvider calls it on every mount, so it is the most frequently hit endpoint in the app. Type an envelope as { data: T; message?: string }, not as a required pair.
Errors
Errors are not enveloped. They carry Nest’s standard shape:
{ "statusCode": 404, "message": "Collection with id CLa7Bk9x2Q not found" }
| Status | Raised by |
|---|---|
| 400 | ValidationPipe — a declared field failed its constraint |
| 401 | AuthGuard — the bearer token is missing, malformed, or expired, or the account is not VERIFIED |
| 403 | OrganizationGuard / OrganizationRoleGuard — no active membership, or insufficient org role |
| 404 | ownership mismatch, deliberately indistinguishable from “does not exist” |
| 409 | unhandled Postgres 23505 unique violation — retryable |
| 429 | ThrottlerGuard — over 60 requests in 60 seconds from this IP |
| 500 | anything unexpected; the real message and stack stay server-side |
The frontend surfaces these through an ApiError carrying status and the parsed body:
export class ApiError extends Error {
status: number;
body: any;
}
Pagination
Paginated endpoints take three query parameters, built by PaginatorBuilder:
| Parameter | Type | Default | Notes |
|---|---|---|---|
page |
string → int | 0 |
Zero-based. Clamped to ≥ 0 |
perPage |
string → int | 10 |
Clamped to [1, 100] |
q |
string | "" |
Case-insensitive LIKE. Collections match on title and description; assets match on asset_name only |
The clamp exists so a caller cannot request ?perPage=100000000 and force a memory or database spike. A non-numeric value falls back to the default rather than erroring.
Queries carry a stable id DESC tiebreaker alongside their primary sort, so rows do not skip or duplicate across pages:
.orderBy("collections.createdAt", "DESC")
.addOrderBy("collections.id", "DESC")
.take(paginator.perPage)
.skip(paginator.page * paginator.perPage)
Scope
Listing endpoints for collections and assets take ?scope=personal|organization, defaulting to personal.
GET /api/collections?scope=organization&q=brand&page=0&perPage=20
GET /api/assets?scope=personal&projectId=PRa7Bk9x2Q
personal filters on the caller’s user_id; organization filters on the resolved orgContext.organizationId. The org context is resolved by OrganizationGuard regardless of scope, so a caller with no active membership gets 403 even when asking for personal rows.
GET /api/assets additionally accepts projectId and collectionId filters.
scope is also a body field on create
Reading is not the only place scope appears. POST /api/collections/create takes it in the body, and it is the only switch that produces an organization-owned collection:
POST /api/collections/create
Content-Type: application/json
{ "title": "Brand assets", "description": "Logos and wordmarks", "scope": "organization" }
| Field | Rules |
|---|---|
title |
required, ≤ 100 characters |
description |
optional, ≤ 2000 characters |
scope |
optional, personal | organization, defaults to personal |
Omit scope and you get a personal collection every time, whatever x-organization-id says. scope: "organization" binds the row to the resolved orgContext.organizationId instead — and because ownership is exclusive, that choice is permanent for the row.
Uploads use a different key for the same idea: ownerScope on POST /api/upload/initiate. See Uploads.
Idempotency and concurrency
There is no idempotency-key support. POST /api/upload/complete is the one write where a retry after a network failure could duplicate an asset — the client is expected to POST /api/upload/abort instead.
Refresh is the one endpoint with explicit replay handling, and it punishes a replay rather than ignoring it: see token rotation.
