Skip to content

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.

RequestResult
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/:id200 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/duplicate201 with the copy, or 404
GET / POST /api/boards/:id/invitations, GET / POST /api/rooms/:id/invitations200 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/:invitationId204, revokes a link
POST /api/invitations/:token/accept200 { 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/open204; the caller opened the board (#730), the backend keeps one entry per few minutes
PUT / DELETE /api/boards/:id/favorite204; star the board for the caller, or take the star away (#730)
GET /api/boards/:id/voting200 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/:elementId200 the voting, or 204 when the caller had no vote there
POST /api/boards/:id/votings/:votingId/close200 the closed voting with its result (an editor or owner)
DELETE /api/boards/:id/votings204; removes the closed votings (an editor or owner)
DELETE /api/boards/:id204 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/:fileId204; the body is an image, streamed to the backend (see Board files below)
GET /api/boards/:id/files/:fileId200 with the image, streamed from the backend, or 404
GET /api/rooms, POST /api/rooms, PATCH /api/rooms/:id, DELETE /api/rooms/:idrooms, 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 userGET /api/boards p50 / p95responseGET /api/templates p50 / p95.../membership/me (floor) p50 / p95
16.6 / 10.6 ms0.2 kB6.0 / 7.3 ms5.8 / 7.0 ms
1015.0 / 5.4 ms16 kB4.5 / 4.9 ms4.8 / 5.3 ms
10017.4 / 8.2 ms160 kB4.1 / 4.4 ms4.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 (on jose) checks signature against the realm's keys, issuer (OIDC_ISSUER_URL), audience (OIDC_AUDIENCE, elysion-bff), expiry (30 s of clock tolerance) and a sub; only RS256 is accepted, so a token cannot pick none or a weaker algorithm. The keys come from OIDC_JWKS_URI, or <issuer>/protocol/openid-connect/certs; jose caches 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.
  • AuthGuard is global (APP_GUARD): no token or an invalid one is 401 with WWW-Authenticate: Bearer; on success request.auth holds the token and its claims.
  • GET /api/auth/verify is what Traefik's forwardAuth middleware calls (#8; the open-source Traefik has no JWT middleware): 200 with X-Auth-User-Id (sub) and X-Auth-User-Email (when the token has one), or 401. Cheap by design: a cached key and a signature check.
  • GET /api/auth/me answers { id, email?, isAdministrator } for the shell: isAdministrator is whether the token's realm_access.roles contains OIDC_ADMIN_ROLE (default elysion-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; its 401, 403 and 404 (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 through pino-pretty in a terminal (LOG_FORMAT=text). LOG_LEVEL is trace, debug, info (default), warn, error or fatal; 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's sub and nothing else about the person; set by AuthGuard once the token is verified, so the final line of the request has it too), err (type, message, stack) on an exception, and context (the Nest class that logged). The one line per finished request adds http: method, route (the Express route pattern, /api/boards/:id; unmatched for a URL no route matches), status, durationMs. /health and /metrics log that line at debug.
  • Request id. The header is X-Request-Id. genReqId and requestIdMiddleware take 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 an AsyncLocalStorage, which BusinessBackendClient sends on every call to the business backend. A value that does not match is replaced and never logged.
  • Rejected tokens. AuthGuard logs Access token rejected at warn with reason (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; redact covers authorization, cookie, token, access_token, password and secret up to three levels deep for the day somebody logs an object. test/logging.e2e-spec.ts sends 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.

VariableDefaultMeaning
PORT3000port of the BFF
BUSINESS_BACKEND_URLhttp://localhost:5174the business backend, whose dev port is set in .vscode/launch.json
OIDC_ISSUER_URLhttp://localhost:8081/realms/elysionthe iss the access tokens carry (identity.md)
OIDC_AUDIENCEelysion-bffthe audience an access token must contain
OIDC_JWKS_URInone (derived from the issuer)where to fetch Keycloak's keys when that is not the issuer's address (inside compose)
WS_TOKEN_SECRETnone, requiredHS256 secret of the WS token, at least 32 characters, the same as in the realtime service
WS_TOKEN_TTL_SECONDS60lifetime of a WS token
LOG_LEVELinfotrace, debug, info, warn, error or fatal (see "Logging")
LOG_FORMATnone: text in a terminal, else jsonjson or text
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTnone (off)also send every log line to this OTLP/HTTP logs endpoint, the dev stack's viewer (see "Logging")
OTEL_EXPORTER_OTLP_LOGS_HEADERSnoneheaders 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.

A pre-release. Released under the MIT license.