BFF spec (NestJS, TypeScript 6)
Owner
Backend
Responsibilities
UI-optimized aggregation of business-backend APIs, Redis caching for heavy UI queries, token exchange for WebSocket handshake. See ADR 0001, ADR 0002.
Endpoints
Under /api, the prefix the gateway routes to the BFF. Backed by the business backend's Board API (docs/specs/business-backend.md); the BFF holds no state of its own.
| Request | Result |
|---|---|
POST /api/csp-report (public) | 204: a Content-Security-Policy violation report (application/csp-report or application/reports+json; at most 8 KB, rate limited at the edge) is logged at warn, one line per request with the directive and the blocked URI, no query, no page URL; garbage and an oversized body are answered 204 and ignored |
GET /api/boards[?query=] | 200, boards newest first |
GET /api/boards/:id | 200 or 404 |
POST /api/boards { "name": "..." } | 201 with the board |
PATCH /api/boards/:id { "name": "..." } | 200 with the renamed board, or 404 |
POST /api/boards/:id/duplicate | 201 with the copy, or 404 |
GET / POST /api/boards/:id/invitations, GET / POST /api/rooms/:id/invitations | 200 the active links; 201 a new link with its token (shown once). Body of POST: { "role": "Editor" or "Viewer", "expiresInHours": 1..720, "maxUses": 1..1000 } (maxUses optional, #734) |
DELETE /api/boards/:id/invitations/:invitationId, DELETE /api/rooms/:id/invitations/:invitationId | 204, revokes a link |
POST /api/invitations/:token/accept | 200 { kind, id, role }: the person joins with the link's role; 404 for an unknown token (a token that cannot be one is answered without asking the backend), 410 for a link that expired, was revoked or is used up. The token is in the path and never in the log: the request line has the route pattern. |
POST /api/boards/:id/open | 204; the caller opened the board (#730), the backend keeps one entry per few minutes |
PUT / DELETE /api/boards/:id/favorite | 204; star the board for the caller, or take the star away (#730) |
GET /api/boards/:id/voting | 200 the current dot voting as the caller sees it, or 204 when there is none (#732, ADR 0029) |
POST /api/boards/:id/votings { "name", "votesPerPerson", "named" } | 201 the new voting; 400, 403 (a viewer), 409 (one is open) |
POST /api/boards/:id/votings/:votingId/votes { "elementId" } | 200 the voting with the caller's votes; 409 closed or no votes left |
DELETE /api/boards/:id/votings/:votingId/votes/:elementId | 200 the voting, or 204 when the caller had no vote there |
POST /api/boards/:id/votings/:votingId/close | 200 the closed voting with its result (an editor or owner) |
DELETE /api/boards/:id/votings | 204; removes the closed votings (an editor or owner) |
DELETE /api/boards/:id | 204 or 404 |
PUT /api/boards/:id/room { roomId } | 200 with the board in or out of a room (roomId: null) |
PUT /api/boards/:id/files/:fileId | 204; the body is an image, streamed to the backend (see Board files below) |
GET /api/boards/:id/files/:fileId | 200 with the image, streamed from the backend, or 404 |
GET /api/rooms, POST /api/rooms, PATCH /api/rooms/:id, DELETE /api/rooms/:id | rooms, see Rooms below |
| GET /api/templates | 200, the template catalog without scenes | | GET /api/templates/:id | 200 with the template and its scene (the text of an .excalidraw file), or 404 |
A board is { id, name, createdAt, path }: the backend's fields plus path, the frontend route that opens it (/board/:id).
Errors: an id that is not a UUID is a 404 without a call to the backend; a body without a string name is a 400; a name the backend rejects (blank, over 120 characters) stays a 400 with the backend's message; an unknown board stays 404; an unreachable or failing backend is a 502 (The business backend is not reachable.), while /health stays up.
Board files (#702): src/boards/files.controller.ts pipes the request body to the business backend and the answer back, so a file is never held in memory (test/files.e2e-spec.ts shows the backend receiving bytes while the client is still sending). Content-Type and Content-Length are required (400, 411); a body that announces more than 64 MiB is a 413 without a call; the type, size and count limits are the backend's, whose 413, 415 and 409 stay as they are. Of the backend's answer only the content type, length, cache and ETag headers and the nosniff and CSP headers are passed on.
JSON bodies up to 6 MB are accepted (src/http-limits.ts), so a template's scene gets through; the backend limits the scene itself.
No caching, on purpose (#351): measured through the edge (Traefik, forwardAuth, BFF, business backend, Postgres) on the dev stack, sequential requests with a token, 60 each:
| Boards of the user | GET /api/boards p50 / p95 | response | GET /api/templates p50 / p95 | .../membership/me (floor) p50 / p95 |
|---|---|---|---|---|
| 1 | 6.6 / 10.6 ms | 0.2 kB | 6.0 / 7.3 ms | 5.8 / 7.0 ms |
| 101 | 5.0 / 5.4 ms | 16 kB | 4.5 / 4.9 ms | 4.8 / 5.3 ms |
| 1001 | 7.4 / 8.2 ms | 160 kB | 4.1 / 4.4 ms | 4.8 / 5.2 ms |
Even with a thousand boards the list costs about 3 ms more than the cheapest authenticated call, which is the fixed price of the edge (forwardAuth plus two hops). A Valkey cache would save part of that 3 ms at the cost of invalidation on every create, rename, delete and membership change (and per-user keys): not worth it. The trigger to revisit: a measured list latency above roughly 100 ms, or a list that needs paging or search (which a cache would not fix either).
Authentication
Every route needs a Keycloak access token (Authorization: Bearer ...; identity.md, ADR 0014) except GET /health, which container health checks call without one (@Public()). src/auth/:
TokenVerifier(onjose) checks signature against the realm's keys, issuer (OIDC_ISSUER_URL), audience (OIDC_AUDIENCE,elysion-bff), expiry (30 s of clock tolerance) and asub; only RS256 is accepted, so a token cannot picknoneor a weaker algorithm. The keys come fromOIDC_JWKS_URI, or<issuer>/protocol/openid-connect/certs;josecaches them and fetches again when a token names an unknown key (rotation). A failure to reach the realm is a server error, not "invalid token". It never calls the business backend.AuthGuardis global (APP_GUARD): no token or an invalid one is401withWWW-Authenticate: Bearer; on successrequest.authholds the token and its claims.GET /api/auth/verifyis what Traefik's forwardAuth middleware calls (#8; the open-source Traefik has no JWT middleware):200withX-Auth-User-Id(sub) andX-Auth-User-Email(when the token has one), or401. Cheap by design: a cached key and a signature check.GET /api/auth/meanswers{ id, email?, isAdministrator }for the shell:isAdministratoris whether the token'srealm_access.rolescontainsOIDC_ADMIN_ROLE(defaultelysion-admin; empty: never). It is a hint for the interface; the business backend checks the role on every admin call.- The board routes forward the caller's own token to the business backend (
@AccessToken()), which decides what the user may do; its401,403and404(no role on a board is a 404 there) are passed on.
In tests the verifier is replaced by one that trusts a locally generated key pair (test/test-auth.ts: signToken, bearer, testVerifier); everything else about the check is the production code.
Role of the caller
GET /api/boards/:id/membership/me → { boardId, role } with role owner, editor or viewer (BoardsController.membership, from the backend's membership/me, lower-cased like the WS token's role); 404 without a role, like every board route. The shell uses it to decide what to show (Share for owners, a read-only canvas for viewers); it is a hint for the interface, the backend and the realtime service enforce the rules.
Board members
/api/boards/:id/members (GET, POST { email, role } or POST { userId, role } (the id GET /api/users returned), PATCH :userId { role }, DELETE :userId; MembersController) passes on to the business backend's member API with the caller's own token (business-backend.md, "Board members"). The BFF only checks the shape: ids that are not UUIDs are 404 and a body without a string role and without an email or a userId (a UUID), or with either of the wrong type, is 400, both without a call to the backend. GET /api/users?query= (UsersController, #663) passes the people search on (up to 10 people with id, displayName, email; 400 from the backend for a query under 2 or over 100 characters; a query given twice counts as none). The backend's answers come back with its message: 403 (a lower role), 404 (a board the caller cannot see, an unknown email, not a member), 409 (already a member, the creator, the last owner), 400 (a bad role).
Rooms
Rooms group boards (ADR 0019, business-backend.md, "Rooms"). RoomsController serves GET /api/rooms ([{ id, name, createdAt, role }], role being the caller's role in the room), POST /api/rooms { name } (201), PATCH /api/rooms/:id { name } and DELETE /api/rooms/:id (204; the boards of the room stay and leave it); BoardsController.move serves PUT /api/boards/:id/room { roomId | null }; RoomMembersController serves /api/rooms/:id/members like the board members; GroupsController serves GET /api/groups?query= and GET /api/me/groups, RoomGroupsController GET, PUT /:groupId { role } and DELETE /:groupId on /api/rooms/:id/groups (ADR 0027: the groups that have a role in a room; 404 for an unknown group, 400 from the backend for a query under two characters). Every board the API returns has roomId (null outside a room). All of it is passed on with the caller's own token and the backend decides: 404 for a room the caller has no role in (also the answer for a room id that does not exist), 403 for a role that is too low, 400 with the backend's message for a refused name, 409 for the member list's rules, 502 when the backend cannot be reached. The BFF only checks the shape, without a call to the backend: ids that are not UUIDs are 404 (in the route and as roomId in the body), a body without a string name is 400, a roomId that is neither a string nor null is 400.
WS tokens
POST /api/realtime/token (src/realtime/, body { "boardId": "<uuid>" }, answer { token, expiresAt }, identity.md) mints the board-scoped credential for the realtime service: the caller's role on the board is looked up with their own token (BusinessBackendClient.getMyRole, the backend's membership/me), no role is 403, a missing boardId is 400, a backend failure 502. WsTokenService signs HS256 with WS_TOKEN_SECRET for WS_TOKEN_TTL_SECONDS (60); claims are sub, boardId, role, iss: elysion-bff, aud: elysion-realtime, iat, exp, typed by @elysion/shared-types. Renewal is the client's business: it asks again on every (re)connect (#312).
Logging
ADR 0025. nestjs-pino with the options of packages/node-logging (@elysion/node-logging, shared with the realtime service so that both write the same line); src/logging/logging.module.ts.
- Format. One JSON object per line on stdout (
LOG_FORMAT=json, the default whenever stdout is not a terminal); readable text throughpino-prettyin a terminal (LOG_FORMAT=text).LOG_LEVEListrace,debug,info(default),warn,errororfatal; both are validated at start like the rest of the configuration. - Fields (the same in the realtime service and the business backend):
timestamp(ISO 8601, UTC),level,service(elysion-bff),requestId,message,userId(the token'ssuband nothing else about the person; set byAuthGuardonce the token is verified, so the final line of the request has it too),err(type,message,stack) on an exception, andcontext(the Nest class that logged). The one line per finished request addshttp:method,route(the Express route pattern,/api/boards/:id;unmatchedfor a URL no route matches),status,durationMs./healthand/metricslog that line atdebug. - Request id. The header is
X-Request-Id.genReqIdandrequestIdMiddlewaretake the value of the request when it matches^[A-Za-z0-9._-]{8,64}$and create a UUID otherwise; it is echoed in the response (and exposed to browsers by the edge's CORS middleware), is on every line of the request, and is the current request id of anAsyncLocalStorage, whichBusinessBackendClientsends on every call to the business backend. A value that does not match is replaced and never logged. - Rejected tokens.
AuthGuardlogsAccess token rejectedatwarnwithreason(expired,not_yet_valid,wrong_issuer,wrong_audience,wrong_algorithm,invalid_signature,malformed,missing_claim,invalid), never the token or the library's message. - Never logged:
Authorization,Cookie,Set-Cookie, query strings (/yjs?token=...is not a BFF route, but the rule is the same everywhere: the line has the route, not the URL), and secrets. The request line carries no headers at all;redactcoversauthorization,cookie,token,access_token,passwordandsecretup to three levels deep for the day somebody logs an object.test/logging.e2e-spec.tssends marker strings in each place and asserts that none reaches the output. - Start-up lines of Nest (module initialisation, mapped routes) come through the same logger at
info.
Configuration
Read once at startup by src/config/ (@nestjs/config, validated by validateEnv) and used through the typed AppConfigService (config.get('PORT') is a number); nothing else reads process.env. A missing or malformed variable stops the process before it listens, with a message that names every problem. For local runs copy apps/bff/.env.example to apps/bff/.env; the compose file sets what the container needs.
| Variable | Default | Meaning |
|---|---|---|
PORT | 3000 | port of the BFF |
BUSINESS_BACKEND_URL | http://localhost:5174 | the business backend, whose dev port is set in .vscode/launch.json |
OIDC_ISSUER_URL | http://localhost:8081/realms/elysion | the iss the access tokens carry (identity.md) |
OIDC_AUDIENCE | elysion-bff | the audience an access token must contain |
OIDC_JWKS_URI | none (derived from the issuer) | where to fetch Keycloak's keys when that is not the issuer's address (inside compose) |
WS_TOKEN_SECRET | none, required | HS256 secret of the WS token, at least 32 characters, the same as in the realtime service |
WS_TOKEN_TTL_SECONDS | 60 | lifetime of a WS token |
LOG_LEVEL | info | trace, debug, info, warn, error or fatal (see "Logging") |
LOG_FORMAT | none: text in a terminal, else json | json or text |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | none (off) | also send every log line to this OTLP/HTTP logs endpoint, the dev stack's viewer (see "Logging") |
OTEL_EXPORTER_OTLP_LOGS_HEADERS | none | headers for those requests, name=value,name2=value2 |
OIDC_* are used by the token verifier, WS_TOKEN_* by WsTokenService.
Open questions
- REST vs GraphQL for BFF-to-frontend contract.
Audit log (#669)
GET /api/admin/audit (filters subject, actor as ids, from, to as dates, page, pageSize; each checked here, a bad value is a 400) and GET /api/boards/:id/history and GET /api/rooms/:id/history (the last 20 entries, for owners) are explicit handlers with fixed backend paths, like the rest of the administration. The backend decides who may read: see business-backend.md, "Audit log".
Administration (ADR 0027, #656)
AdminController (src/admin/) has one handler for each call of the business backend's /admin (business-backend.md, "Administration"): GET stats, users, users/:id, groups, groups/:id/members, groups/:id/pending (the people the Graph sync found who have not signed in yet, #667), graph (the state of that sync), rooms, POST groups, POST users/:id/deactivate, POST users/:id/reactivate, POST boards/transfer (#668: { fromUserId, toUserId }, both UUIDs, else 400 without a backend call), PATCH and DELETE groups/:id, PUT and DELETE groups/:id/members/:userId, all under /api/admin. Each is passed on with the caller's own token and BusinessBackendClient builds the backend address from a fixed path, ids that passed the UUID check (a non-UUID is 404 without a call, and the id is encoded anyway) and a query made by URLSearchParams from three values only: query (one text, at most 100 characters), page and pageSize (whole numbers); anything else in the query is dropped, a repeated or malformed value is 400. No method, path or address is ever taken from the request (CodeQL's js/request-forgery). A group change takes only name (text) and externalId (text or null). The BFF holds no rule of its own: the backend checks the administrator role on every call and its status and body come back as they are (201 for a made group, 204 for member changes and deletes, 403 for a caller without the role, 404, 409, 400).
A 403 of the business backend that carries the problem code account-deactivated (the person was deactivated, #668) comes back as a 403 whose body has code: "account-deactivated" (ACCOUNT_DEACTIVATED in business-backend.client.ts), so that the shell can tell it from any other 403 and show its page; every other 403 stays an empty ForbiddenException. POST /api/realtime/token gets the same 403 for such a person, because it asks the backend for the caller's role as the caller.