Backend Architecture¶
The backend is a NestJS application in backend/src/ using modular architecture with Prisma ORM, Socket.IO, and LiveKit integration.
Module Organization¶
Core Infrastructure¶
| Module | Purpose |
|---|---|
auth/ | JWT authentication, Passport strategies, RBAC guards |
database/ | Prisma service and connection management |
redis/ | Redis connection, pub/sub |
cache/ | Redis caching service |
roles/ | Role definitions and permission checking |
health/ | Health check endpoints |
User & Social¶
| Module | Purpose |
|---|---|
user/ | User CRUD, profiles, avatars, banners |
friends/ | Friend requests, accept/decline/block |
presence/ | Online status tracking (multi-connection aware) |
appearance-settings/ | Theme preferences (accent color, intensity) |
Community & Channels¶
| Module | Purpose |
|---|---|
community/ | Community CRUD, settings, invites |
channels/ | Text/voice channel management, reordering |
membership/ | Community membership (join/leave) |
channel-membership/ | Private channel access control |
invite/ | Instance and community invitation system |
onboarding/ | First-time instance setup wizard |
alias-groups/ | Mention groups within communities |
Messaging¶
| Module | Purpose |
|---|---|
messages/ | Message CRUD with spans, attachments, reactions |
threads/ | Thread replies, subscription, notifications |
direct-messages/ | 1:1 and group DM management |
notifications/ | Notification creation and delivery |
read-receipts/ | Message read tracking |
Voice & Media¶
| Module | Purpose |
|---|---|
livekit/ | Token generation, room management, replay buffer |
rooms/ | Room state for voice/video sessions |
file/ | Authenticated file serving with range support |
file-upload/ | File upload handling |
storage/ | Storage abstraction layer |
storage-quota/ | Per-user storage quota enforcement |
Moderation¶
| Module | Purpose |
|---|---|
moderation/ | Ban, kick, timeout, moderation logs |
instance/ | Instance-level settings and admin panel |
Request Flow¶
HTTP Request
|
Controller (route + decorators)
|-- @RequiredActions(RbacActions.CREATE_MESSAGE)
|-- @RbacResource({ type: CHANNEL, idKey: 'channelId', source: PAYLOAD })
|
Guards (JwtAuthGuard -> RbacGuard)
|
Service (business logic)
|
Prisma (database operations)
|
EventEmitter2 (domain events) --> RoomSubscriptionHandler --> WebsocketService
Authentication Flow¶
- Login:
POST /auth/login-> validates credentials -> returns JWT access + refresh tokens - Access: Every request includes
Authorization: Bearer <token>->JwtAuthGuardvalidates - Refresh:
POST /auth/refresh-> validates refresh token -> issues new pair - RBAC:
RbacGuardchecks user's roles against@RequiredActions()decorator
Access tokens live 1 hour and carry a session id (sid, the refresh token family). Revocation is remembered in Redis until the tokens would have expired anyway (TokenBlacklistService): logout revokes that token and its session, revoking a session revokes all of its tokens, and a password reset revokes every token the user holds. REST (JwtStrategy) and WebSocket auth check the same revocations.
Refresh tokens rotate on every refresh. A rotated token presented again within 30 seconds (tabs sharing the refresh cookie, a retry after a lost response) gets the token it was rotated to, not a new one (RefreshTokenGraceService), so the session never forks. Only the client that did the rotation gets it: the same user agent and IP address (the same /64 for IPv6, client-match.util.ts; req.ip, so behind a reverse proxy set TRUST_PROXY). Presented later, or within the window by another client, it counts as stolen: the whole session is revoked, including its access tokens and sockets. Rotation times come from the database's clock, so backend instances with clocks that disagree agree on the window. Logins, refreshes and revocations of a user's sessions are serialized with a lock on the user's row (session-lock.util.ts), so none of them can leave a token behind that a concurrent password reset or logout should have removed.
GET /auth/sessions lists a session by its id (the token family, the sid of its access tokens), which rotations don't change, and shows a rotation's activity only a minute later, after its grace window: the list can't tell the holder of a stolen token when to replay it. DELETE /auth/sessions/:id takes that id (or, for older clients, a refresh token id).
POST /auth/refresh is rate-limited per presented token (its jti, once its signature checks out) rather than per IP (RefreshThrottlerGuard): every page load refreshes, and many users can share one address. Not per user either: a logged-out or rotated token stays validly signed, so whoever holds one of a user's old tokens could otherwise use up that user's quota. A request without a valid token is limited per IP. A refresh token that fails verification (expired, signed with another secret, malformed) gets a 401, like any refused session.
On the client, tokenService refreshes under a Web Lock, so tabs take turns instead of sending the same cookie at once. It signs out only when the server refuses the session (401/403); a network error, 5xx or 429 is retried, and the REST call that needed the refresh fails with a retryable 503. Retries stop 12 seconds after the first attempt (sessionRefreshPolicy.ts), so one that re-presents a rotated token still lands inside the grace window. On a page load (AuthGate) the app keeps showing "Connecting..." and tries again while the server can't answer, instead of showing the login page. After about 40 seconds of that (PAGE_LOAD_REFRESH_ROUNDS), it says it can't reach the server and offers "Try again" and "Sign in again", so a server that keeps failing doesn't leave the user on the spinner.
RBAC Pattern¶
@RequiredActions(RbacActions.CREATE_MESSAGE)
@RbacResource({
type: RbacResourceType.CHANNEL,
idKey: 'channelId',
source: ResourceIdSource.PAYLOAD,
})
@Post()
async createMessage(@Body() dto: CreateMessageDto) { ... }
The guard resolves the resource (channel -> community), loads the user's roles for that community, and checks if any role includes the required action.
WebSocket Architecture¶
Gateways¶
- MessagesGateway --
SEND_MESSAGE,EDIT_MESSAGE,DELETE_MESSAGE, reactions, typing - PresenceGateway -- User connect/disconnect, online status
Both gateways use JwtWsGuard for authentication.
Socket Sessions¶
A socket authenticates once, in RoomsGateway's connection middleware, and stays bound to that access token (SocketSessionService):
- It joins
user:<id>,session:<sid>andtoken:<jti>right away. Ending sessions (SessionRevocationService-> domain event ->SessionRevocationHandler) disconnects exactly those rooms withdisconnectSockets(), on every instance through the Redis adapter. The client first getsSESSION_TERMINATEDwith the reason:LOGGED_OUT,SESSION_REVOKED,PASSWORD_CHANGED,ACCOUNT_BANNED,ACCOUNT_DELETEDorTOKEN_EXPIRED. - It ends when its token expires. Two minutes before, the server sends
TOKEN_EXPIRING; the client refreshes its token and sends it withREAUTHENTICATE, which keeps the connection.SocketProviderdoes this, and refreshes and reconnects after aSESSION_TERMINATEDdisconnect (or signs out when the session can't be refreshed).
A community ban or kick doesn't end the session: it takes the user's sockets out of that community's rooms (RoomSubscriptionHandler).
Voice Access Revocation¶
Requires LiveKit server 1.7 or later (participant attributes).
LiveKit tokens (1 hour) can't be revoked, and LiveKit only checks them at join. Every token the backend issues therefore carries two attributes, each HMAC-signed with the LiveKit API secret and bound to the identity, because clients may rewrite their own attributes (they need canUpdateOwnMetadata to publish their deafen state):
semaphore.issuedAt: when the token was issued;semaphore.sessionId: the auth session (the access token'ssid) it was issued to, absent if the request had none.
A value that doesn't verify counts as absent.
Whole account (PASSWORD_CHANGED, from a change or a reset, ACCOUNT_BANNED, ACCOUNT_DELETED): SessionRevocationHandler
- records a cutoff in Redis (
LivekitAccessService, kept for a day): LiveKit tokens issued up to now are stale; - removes the user from every LiveKit room they are in, community voice channels and DM calls alike, found by asking LiveKit (
listRooms+listParticipants) merged with the voice presence index, and drops their voice presence.
One session (LOGGED_OUT, SESSION_REVOKED): only the participants whose session attribute names a revoked session are removed (and their presence in those rooms dropped). The user's other devices stay in their calls, and so do participants whose token records no session (issued before sessions were recorded).
The participant_joined webhook is the safety net. It removes a participant whose user is deleted or banned, whose token was issued before the cutoff (a token without a valid issue time counts as stale while a cutoff is in force), or whose token's session is revoked (the same Redis marker access tokens are checked against, TokenBlacklistService). That is one primary key read and one Redis round trip per join, and lookup errors let the join through.
WebsocketService¶
Central service for broadcasting events to rooms:
this.websocketService.sendToRoom(
RoomName.community(communityId),
ServerEvents.CHANNEL_CREATED,
payload,
);
Redis Adapter¶
In multi-pod deployments, RedisIoAdapter ensures Socket.IO events reach all connected clients regardless of which pod they're connected to.
Event Emission¶
Two patterns coexist:
- EventEmitter2 -- Services emit domain events;
RoomSubscriptionHandlertranslates to WebSocket broadcasts. Used when the service doesn't haveWebsocketServiceinjected. - Direct -- Gateways and hybrid services call
websocketService.sendToRoom()directly.
See WebSocket Patterns for the full guide.
Key Patterns¶
Sensitive User Fields¶
Never return raw Prisma User objects. Always wrap in new UserEntity(user) which applies @Exclude() decorators. Use PUBLIC_USER_SELECT constant for database queries to avoid fetching sensitive fields.
OpenAPI/Swagger¶
- Add
@ApiProperty({ enum: XxxValues })for Prisma enums (NestJS Swagger plugin can't introspect them) - Import
PartialTypefrom@nestjs/swagger, not@nestjs/mapped-types - Add
@ApiOkResponse({ type: FooDto })to controllers for typed responses
File Serving¶
Files are served through authenticated endpoints with range request support for video streaming. The FileService validates access permissions before serving.