A real-time anonymous chat backend built with NestJS, TypeORM (PostgreSQL), Redis, and Socket.IO. This system provides ephemeral chat rooms with advanced features like presence tracking, polls, mentions, message pinning, and comprehensive moderation tools.
- Overview
- Architecture & Tech Stack
- Installation & Setup
- Environment Variables
- Database Schema
- Redis Data Structures
- REST API Endpoints
- WebSocket Events
- System Flows
- Session Management
- Presence System
- Features
- Security & Rate Limiting
- Development Workflow
This backend powers anonymous chat rooms where:
- Users can create temporary or persistent chat rooms
- Each room has a unique invite token (and optional moderator token)
- Messages can be ephemeral (auto-deleted based on retention policy)
- Real-time presence tracking shows who's online
- Advanced features: polls, mentions, replies, message pinning
- Comprehensive moderation: kick/ban users, close rooms
- Rate limiting and fingerprint-based abuse prevention
- NestJS - Modern TypeScript framework with modular architecture
- TypeORM - ORM for PostgreSQL with migration support
- PostgreSQL - Primary database for persistent data
- Redis - Session storage, presence tracking, pub/sub messaging
- Socket.IO - Real-time WebSocket communication
- ioredis - Redis client library
src/
├── app.module.ts # Root module
├── main.ts # Bootstrap with CORS, validation
├── typeorm.config.ts # Database configuration
├── rooms/ # Room creation, validation, management
├── participants/ # Participant tracking, bans
├── auth/ # Session & token management
├── messages/ # Messages, polls, notifications
├── websocket/ # Socket.IO gateway
├── redis/ # Redis client service
├── analytics/ # Usage metrics
├── health/ # Health checks
└── common/ # Shared utilities, decorators, rate limiting
Key modules:
- RoomsModule: Room lifecycle (create, join, close, end)
- AuthModule: Session creation and validation
- WebsocketModule: Real-time chat gateway
- MessagesModule: Message persistence, polls, notifications
- ParticipantsModule: Participant records, ban management
- RedisModule: Redis client and subscriber instances
- AnalyticsModule: Metrics tracking
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
- npm or yarn
-
Install dependencies
npm install
-
Set up environment variables Create a
.envfile in the root directory:# Database DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_USER=postgres DATABASE_PASS=postgres DATABASE_NAME=anonchat # Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= # Application PORT=5000 NODE_ENV=development FRONTEND_URL=http://localhost:3000 # Session SESSION_TTL_SECONDS=604800 # 7 days
-
Run database migrations
npm run migration:run
-
Start development server
npm run start:dev
Server runs on
http://localhost:5000
docker-compose up --buildThis starts:
- Backend service (port 5000)
- PostgreSQL (port 5432)
- Redis (port 6379)
| Variable | Description | Default |
|---|---|---|
DATABASE_HOST |
PostgreSQL host | localhost |
DATABASE_PORT |
PostgreSQL port | 5432 |
DATABASE_USER |
Database username | postgres |
DATABASE_PASS |
Database password | postgres |
DATABASE_NAME |
Database name | anonchat |
REDIS_HOST |
Redis host | localhost |
REDIS_PORT |
Redis port | 6379 |
REDIS_PASSWORD |
Redis password | (empty) |
PORT |
Application port | 5000 |
NODE_ENV |
Environment | development |
FRONTEND_URL |
Frontend origin for CORS | (required) |
SESSION_TTL_SECONDS |
Session expiry time | 604800 (7 days) |
{
id: string (uuid, PK)
title: string
description?: string
inviteTokenHash: string // bcrypt hash
moderatorTokenHash?: string // bcrypt hash
isOpen: boolean // Room open for new joins
ephemeral: boolean // Messages auto-delete
retentionSeconds: number // How long messages persist
chatDurationSeconds: number // Room auto-close timer
maxParticipants: number
actualParticipants: number
settings: json // Additional config
creatorIP?: string
creatorAgent?: string
createdAt: timestamp
closedAt?: timestamp
}{
id: string (uuid, PK)
roomId: string (uuid)
username: string
anonymousId: string // 6-char hex for privacy
sessionId?: string
avatar?: string
joinedAt: timestamp
leftAt?: timestamp
isModerator: boolean
}{
id: string (uuid, PK)
roomId: string (uuid)
authorUsername: string
anonymousId?: string
authorAvatar?: string
authorSessionId?: string
authorRole?: string
clientMsgId?: string // For client-side dedup
content: json // { text?: string, type: 'text' | 'poll' }
replyToId?: string (uuid) // Parent message
mentions?: string[] // Mentioned usernames
pinned: boolean
pinnedAt?: timestamp
pinnedBy?: string
ip?: string
userAgent?: string
createdAt: timestamp
}{
id: string (uuid, PK)
roomId: string (uuid)
messageId: string (uuid) // Associated message
question: string
options: json[] // [{ id, text }]
allowMultipleAnswers: boolean
isClosed: boolean
closedAt?: timestamp
creatorUsername: string
creatorAnonymousId?: string
creatorSessionId?: string
createdAt: timestamp
}{
id: string (uuid, PK)
pollId: string (uuid)
optionId: string // References poll.options[].id
sessionId: string // Voter session
username: string
anonymousId: string
createdAt: timestamp
}{
id: string (uuid, PK)
roomId: string (uuid)
fingerprintHash?: string
deviceId?: string
ip?: string
userAgent?: string
bannedBy: string // Moderator username
reason?: string
createdAt: timestamp
expiresAt?: timestamp
}Used to limit room creation per fingerprint.
{
id: string (uuid, PK)
roomId: string (uuid)
recipientUsername: string
recipientSessionId: string
type: string // 'mention' | 'reply' | 'poll_vote' | etc.
content: json
messageId?: string (uuid)
read: boolean
createdAt: timestamp
}Redis is used for session storage, presence tracking, and pub/sub messaging.
session:<sessionId> → JSON { sessionId, roomId, username, anonymousId, avatar, role, createdAt, ... }
session_token:<token> → sessionId
username_session:<roomId>:<username> → sessionId
- TTL:
SESSION_TTL_SECONDS(default 7 days) - Sessions map tokens to user state
- Username→session mapping prevents hijacking
presence:<roomId> → Hash { sessionId: JSON { username, anonymousId, role, ... } }
- Stores currently connected users per room
- Updated on join/leave via WebSocket
room:<roomId>:username:<username> → "1"
- TTL:
SESSION_TTL_SECONDS - Ensures usernames are unique per room
- Created on join, checked on connect
room:<roomId>:messages → Published messages broadcast to all connected clients
- Gateway subscribes with pattern
room:*:messages - Enables horizontal scaling (multiple backend instances)
All endpoints are prefixed with /api.
Create a new room.
Request Body:
{
"title": "My Chat Room",
"description": "Optional description",
"ephemeral": true,
"chatDurationSeconds": 3600,
"retentionSeconds": 2592000,
"maxParticipants": 100,
"fingerprintHash": "abc123...",
"deviceId": "device-uuid"
}Response:
{
"roomId": "uuid",
"inviteToken": "plain-token",
"moderatorToken": "plain-moderator-token"
}Rate Limit: 5 rooms per fingerprint/IP per hour
Get room metadata.
Response:
{
"roomId": "uuid",
"title": "Room Title",
"description": "Description",
"ephemeral": true,
"createdAt": "ISO-8601",
"isOpen": true,
"actualParticipants": 5,
"closedAt": null
}Validate invite token before join.
Request Body:
{
"inviteToken": "plain-token"
}Response:
{
"ok": true
}Join a room and create a session.
Request Body:
{
"inviteToken": "plain-token",
"moderatorToken": "optional-mod-token",
"username": "Alice",
"avatar": "optional-avatar-url",
"fingerprintHash": "fingerprint-hash",
"deviceId": "device-uuid"
}Response:
{
"sessionToken": "hex-token",
"sessionId": "uuid",
"anonymousId": "6-char-hex",
"role": "participant" | "moderator"
}Notes:
- Checks if username is taken
- Checks if user is banned
- Creates session in Redis
- Locks username
- Records participant in database
Leave a room (cleanup session, presence).
Headers: Authorization: Bearer <sessionToken>
Response: 200 OK
Close room to new participants (moderator only).
Headers: Authorization: Bearer <sessionToken>
Response: 200 OK
End the room permanently (moderator only).
Headers: Authorization: Bearer <sessionToken>
Response: 200 OK
Kick a user from the room (moderator only).
Headers: Authorization: Bearer <sessionToken>
Request Body:
{
"targetSessionId": "uuid"
}Response: 200 OK
Ban a user from the room (moderator only).
Headers: Authorization: Bearer <sessionToken>
Request Body:
{
"targetSessionId": "uuid",
"reason": "Optional reason"
}Response: 200 OK
Get active participants.
Response:
{
"participants": [
{
"sessionId": "uuid",
"username": "Alice",
"anonymousId": "abc123",
"role": "participant",
"avatar": "url"
}
]
}Get room time remaining before auto-close.
Response:
{
"remainingSeconds": 3420
}WebSocket namespace: / (root)
Connect with query param: ?t=<sessionToken>
Authenticate after connecting without token.
Payload:
{
"token": "session-token"
}Response Events:
identified- Authentication successfulerror- Authentication failed
Send a chat message.
Payload:
{
"text": "Hello world",
"clientMsgId": "client-uuid",
"replyToId": "optional-message-uuid"
}Response Events:
message_ack- Message saved, broadcastederror- Message failed
Rate Limit: 10 messages per 10 seconds per session
Indicate user is typing.
Payload:
{
"typing": true
}Broadcast Event: typing to room with { username, anonymousId, typing }
Create a poll (moderator only).
Payload:
{
"question": "What's your favorite color?",
"options": ["Red", "Blue", "Green"],
"allowMultipleAnswers": false
}Broadcast Event: message with poll message
Vote on a poll.
Payload:
{
"pollId": "uuid",
"optionId": "option-id"
}Response Events:
vote_recorded- Vote savedpoll_update- Updated poll results broadcast to room
Remove your vote from a poll.
Payload:
{
"pollId": "uuid",
"optionId": "option-id"
}Response Events:
vote_removedpoll_update
Close a poll (moderator only).
Payload:
{
"pollId": "uuid"
}Broadcast Event: poll_closed
Get detailed poll results.
Payload:
{
"pollId": "uuid"
}Response Event: poll_results
Pin a message (moderator only).
Payload:
{
"messageId": "uuid"
}Broadcast Event: message_pinned
Unpin a message (moderator only).
Payload:
{
"messageId": "uuid"
}Broadcast Event: message_unpinned
Get all pinned messages in room.
Response Event: pinned_messages
Get unread notifications for user.
Response Event: notifications
Mark a notification as read.
Payload:
{
"notificationId": "uuid"
}Mark all notifications as read.
Sent after successful authentication.
Payload:
{
"sessionId": "uuid",
"roomId": "uuid",
"username": "Alice",
"role": "participant"
}Full presence snapshot sent on connect.
Payload:
{
"users": [
{
"sessionId": "uuid",
"username": "Alice",
"anonymousId": "abc123",
"role": "participant",
"avatar": "url"
}
]
}User joined or left.
Payload:
{
"event": "join" | "leave",
"user": {
"sessionId": "uuid",
"username": "Alice",
"anonymousId": "abc123"
}
}Specific user was removed (kicked/banned).
Payload:
{
"sessionId": "uuid"
}Message history sent on connect.
Payload:
{
"messages": [ /* array of message objects */ ]
}New message broadcast to room.
Payload:
{
"id": "uuid",
"roomId": "uuid",
"authorUsername": "Alice",
"anonymousId": "abc123",
"content": { "text": "Hello" },
"createdAt": "ISO-8601",
"replyToId": "optional-uuid",
"mentions": ["Bob", "Charlie"]
}Acknowledgement of sent message.
Payload:
{
"clientMsgId": "client-uuid",
"serverMessageId": "server-uuid"
}User typing indicator.
Payload:
{
"username": "Alice",
"anonymousId": "abc123",
"typing": true
}Poll vote counts updated.
Room closed by moderator.
You were kicked from the room.
Error message.
Payload:
{
"message": "Error description"
}- Client sends
POST /api/roomswith room details and fingerprint - Backend checks rate limit (5 rooms/hour per fingerprint)
- Backend generates:
- Room UUID
- Invite token (plain) + bcrypt hash
- Moderator token (plain) + bcrypt hash
- Backend stores room in PostgreSQL
- Backend records fingerprint association
- Backend returns
{ roomId, inviteToken, moderatorToken }
- Client optionally validates invite via
POST /api/rooms/:id/validate-invite - Client sends
POST /api/rooms/:id/joinwith:inviteTokenormoderatorTokenusernamefingerprintHash,deviceId
- Backend validates token (bcrypt compare)
- Backend checks:
- Is room open?
- Is username available?
- Is user banned?
- Has room hit max participants?
- Backend creates session in Redis:
- Generates
sessionToken,sessionId,anonymousId - Sets role:
moderatorif moderator token provided, elseparticipant - Maps
session:<sessionId>,session_token:<token>,username_session:<roomId>:<username>
- Generates
- Backend sets username lock:
room:<roomId>:username:<username> - Backend destroys any old sessions for this username
- Backend records participant in database
- Backend returns
{ sessionToken, sessionId, anonymousId, role }
- Client connects via Socket.IO with
?t=<sessionToken>or sendsidentifyevent - Backend retrieves session from Redis via
session_token:<token> - Backend validates:
- Session exists?
- Username lock still held?
- No conflicting session using same username?
- Backend checks for duplicate connection (within 2 seconds) to prevent double presence
- Backend attaches session data to socket:
client.data.session - Backend joins socket to room:
room_<roomId> - Backend adds to presence hash:
presence:<roomId> - Backend sends to client:
identifiedeventpresence_state(full user list)history_chunk(recent messages)pinned_messages
- Backend broadcasts
presenceevent (join) to room
- Client sends
send_messageevent with{ text, clientMsgId, replyToId? } - Backend checks rate limit (10 messages / 10 seconds)
- Backend extracts mentions from text (e.g.,
@Alice) - Backend saves message to PostgreSQL
messagestable - Backend publishes message to Redis:
PUBLISH room:<roomId>:messages <json> - Backend creates notifications for mentioned users
- Backend sends
message_ackto sender - Redis subscriber receives published message
- Backend broadcasts
messageto all clients inroom_<roomId>
Sessions are stateless tokens stored in Redis.
- On room join,
SessionService.createSession()generates:sessionId(UUIDv4)sessionToken(64-char hex)anonymousId(6-char hex, for privacy)
- Session payload includes:
roomId,username,role,avatar,ip,userAgent,fingerprintHash,deviceId - TTL:
SESSION_TTL_SECONDS(default 604800 = 7 days)
- On WebSocket connect, token is validated via
getSessionByToken() - Username lock key is checked:
room:<roomId>:username:<username> - If lock expired, session is destroyed and connection rejected
destroyOldSessionsForUsername()removes any existing session with same username before creating new one- Prevents username theft when someone reconnects
- Cleans up old session keys, tokens, username mappings, and presence entries
- On leave/disconnect:
destroySession()ordestroySessionById() - Removes: session keys, token keys, username mappings, presence hash entries
- Username lock expires naturally via TTL
Presence tracks who's currently connected to each room.
- Redis Hash:
presence:<roomId>- Key:
sessionId - Value: JSON
{ username, anonymousId, role, avatar }
- Key:
- Join: Added to hash on WebSocket connect,
presenceevent broadcast - Reconnect: Duplicate check prevents double-emission
- Leave: Removed from hash on disconnect,
presenceevent broadcast - Kick/Ban: Removed +
presence_removedevent broadcast
ChatGateway.activeConnectionsMap tracks recent connections- Prevents duplicate presence events when client reconnects quickly (<2s)
- Text messages with @mentions
- Message replies (threading)
- Ephemeral messages (auto-delete based on room retention)
- Message acknowledgments (clientMsgId → serverMessageId)
- Create polls with multiple options
- Single or multiple answer modes
- Vote/unvote
- Real-time vote count updates
- Close poll (moderator only)
- View detailed results
- Mention notifications
- Reply notifications
- Poll vote notifications
- Persistent in database
- Mark as read
- Moderators can pin important messages
- Pinned messages sent to new joiners
- Unpin support
- Kick users (disconnect + remove presence)
- Ban users (prevents rejoin via fingerprint/IP/deviceId)
- Close room (prevent new joins)
- End room (permanent shutdown)
- Track: sessions, messages, rooms created, failed auths
- Exposed via
/api/analyticsendpoints
Implemented via RateLimitService (Redis-backed).
- Room Creation: 5 rooms per fingerprint/IP per hour
- Message Send: 10 messages per 10 seconds per session
- Join Room: 5 join attempts per fingerprint per 5 minutes
- Multi-factor banning:
fingerprintHash,deviceId,ip,userAgent - Persistent in
room_banstable - Checked on join and connect
- Client-side fingerprinting (browser fingerprint)
- Sent as
fingerprintHashin requests - Used for rate limiting and ban enforcement
- Global
ValidationPipewithwhitelist,forbidNonWhitelisted,transform - DTOs with
class-validatordecorators
- Configurable allowed origins via
FRONTEND_URLand hardcoded domains - Credentials enabled for session cookies (if used)
# Development with watch mode
npm run start:dev
# Production build
npm run build
npm run start:prod# Generate migration from entity changes
npm run migration:generate
# Run pending migrations
npm run migration:run
# Revert last migration
npm run migration:revert
# Production migrations
npm run migration:run:prod
npm run migration:revert:prod# Unit tests
npm run test
# Watch mode
npm run test:watch
# E2E tests
npm run test:e2e
# Coverage
npm run test:cov# Start all services
docker-compose up --build
# Stop services
docker-compose down
# View logs
docker-compose logs -f backendsrc/main.ts- Bootstrap, CORS, global validation pipesrc/app.module.ts- Root module with TypeORM, Redis, all feature modulessrc/typeorm.config.ts- TypeORM CLI config for migrationssrc/redis/redis.service.ts- Redis client and subscriber instancessrc/websocket/chat.gateway.ts- Socket.IO gateway (all realtime logic)src/auth/session.service.ts- Session CRUD and anti-hijack logicsrc/rooms/rooms.service.ts- Room business logicsrc/messages/messages.service.ts- Message persistence and retrievalsrc/messages/polls.service.ts- Poll operations
- Redis Key Prefixes: Consistent naming (
session:,presence:,room:) - Socket Rooms: Named
room_<roomId>(underscore prefix) - Pub/Sub: Pattern
room:*:messagesfor message broadcasting - Presence: Hash key stores JSON, updated on join/leave
- Session TTL: All session-related keys share same TTL
- Logging: Nest
Loggerwith context strings
All endpoints are prefixed with /api (configured via app.setGlobalPrefix('api') in main.ts).
Example: http://localhost:5000/api/rooms
Connect to: ws://localhost:5000/?t=<sessionToken>
Or connect without token and send identify event with { token: "..." }.
UNLICENSED
Harrison Ikpefua
For more details on specific features like polls implementation, see POLLS_IMPLEMENTATION.md.