Forte 🔗
Forte is a FastAPI backend for a real-time chat application. It provides JWT-based authentication as well as cookies and a single WebSocket connection per client that supports a global chat channel plus ad-hoc, host-owned "rooms." Redis is used both as the pub/sub backbone (so the app can run behind multiple workers/instances) and as the source of truth for room membership, online/offline presence, and short message history. Postgres (via SQLAlchemy + Alembic) stores user accounts. Plan to add room member, room details, host data in Postgres in future development.
- Architecture
- Tech stack
- Project structure
- Auth model & cookies
- Setup
- REST API reference
- WebSocket protocol reference
- Redis key reference
flowchart LR
subgraph Client
FE["Frontend (fortefrontend.vercel.app)"]
end
subgraph API["FastAPI app (application/main.py)"]
AUTH["/login, /createuser, /getuser\n(auth.py, user.py)"]
WS["/ws\n(websockets.py)"]
PDS["/online_users, /room_members\n(persistantDataShareing.py)"]
CM["ConnectionManager\n(services/connection_manager.py)"]
end
PG[("Postgres\nusers table")]
R[("Redis\npub/sub + state + history cache")]
FE -- "HTTP: login / signup" --> AUTH
AUTH -- "cookie: access_token (JWT)" --> FE
FE -- "WS: cookie access_token" --> WS
WS --> CM
AUTH --> PG
CM -- "publish/subscribe\nGLOBAL_CHANNEL, room:broadcast:*" --> R
CM -- "room/user/host state,\nonline_users, message cache" --> R
PDS --> R
Key points:
- Every process runs its own
ConnectionManager(application/services/connection_manager.py), which holds only the local, in-process websocket objects (active_connectionsfor global,roomsfor room membership). Everything that needs to be shared across processes/instances — room existence, membership, host, per-user room list, presence counts, and the last 100 messages per channel — lives in Redis, not in memory. - On startup (
lifespaninwebsockets.py) the manager subscribes to the Redisglobal_broadcastchannel and starts a background task (_pubsub_listner_loop) that fans incoming pub/sub messages out to whichever local websockets are relevant (global connections, or connections currently in a given room). This is what lets a message sent to one worker, reach clients connected to a different worker. - Presence (
online_usershash in Redis) is reference-counted per username, so the same user connected from multiple tabs/devices is only reported offline once every connection has dropped. redis hset is used for that purpose.
- FastAPI (0.141.x) + Starlette websockets
- SQLAlchemy as ORM, Alembic for migrations, Postgres (Neon,
.env.example) - Redis (
redis.asyncio) for pub/sub, room/presence state, and message history cache - python-jose for JWT, passlib[bcrypt] for password hashing
- Pydantic v2 / pydantic-settings for schemas and config
- uvicorn (dev) / gunicorn + uvicorn workers (prod)
- Deployed as a Docker image (
kiertolainen/forte:latest)
forte-master/
├── application/
│ ├── main.py # FastAPI app, CORS, router registration
│ ├── config.py # pydantic-settings, reads .env
│ ├── database.py # SQLAlchemy engine/session, get_db dependency
│ ├── models.py # Users ORM model
│ ├── schemas.py # Pydantic request/response/WS message schemas
│ ├── oauth2.py # JWT creation/verification, HTTP + WS auth deps
│ ├── utils.py # bcrypt hash/verify helpers
│ ├── routers/
│ │ ├── auth.py # POST /login
│ │ ├── user.py # POST /createuser, GET /getuser/{username}
│ │ ├── websockets.py # WS /ws, lifespan (starts redis pubsub listener)
│ │ └── persistantDataShareing.py # GET /online_users, GET /room_members
│ └── services/
│ └── connection_manager.py # ConnectionManager: rooms, presence, pub/sub fanout
├── alembic/ # migrations (env.py, versions/)
├── alembic.ini
├── Dockerfile
├── docker-compose-dev.yml # api + postgres + redis
├── docker-compose-prod.yml # api only (gunicorn), external Postgres/Redis
├── requirements.txt
└── .env.example
Login (POST /login) issues a JWT ({"id": user_id, "exp": ...}, HS256) and returns
it in the response body and sets it as a cookie:
| Attribute | Value | Why |
|---|---|---|
key |
access_token |
|
httponly |
True |
not readable from JS — mitigates XSS token theft |
secure |
True |
only sent over HTTPS |
samesite |
"none" |
required because the frontend (fortefrontend.vercel.app) and API are on different origins; "strict"/"lax" would block the cookie cross-site |
max_age |
3600 (1 hour) |
matches typical ACCESS_TOKEN_EXPIRE_MINUTES usage |
Two different auth dependencies exist because a WebSocket object and an HTTP
Request object don't share the same interface:
get_current_user(used by normal HTTP routes): triesAuthorizationheader viaOAuth2PasswordBearerfirst, then falls back to theaccess_tokencookie (get_token_from_request).get_current_user_ws(used by/ws):OAuth2PasswordBearercannot read a token off aWebSocketobject (it only knows how to look atRequest.headers), so the websocket path readswebsocket.cookies.get("access_token")directly and rejects withWebSocketException(code=status.WS_1008_POLICY_VIOLATION)(instead of anHTTPException, which isn't valid for a websocket handshake) if the cookie is missing or the JWT fails verification.
Practically: the client must call /login with cookies enabled (credentials: "include" on the frontend) so the browser stores access_token, and the same cookie
is then sent automatically on the /ws handshake.
Copy .env.example to .env and fill in:
DATABASE_HOSTNAME=
DATABASE_PORT=5432
DATABASE_USERNAME=neondb_owner
DATABASE_PASSWORD=
DATABASE_NAME=neondb
SECRET_KEY= # random secret for JWT signing
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=
REDIS_URL=redis://redis:6379
docker compose -f docker-compose-dev.yml up --buildThis spins up api (hot-reload via --reload), a local postgres, and a local
redis:7-alpine. The API is available at http://localhost:8000. Do change the
allowed cors origin before starting the server.
alembic upgrade head(Run inside the api container, or locally against DATABASE_URL, whichever
matches your workflow — alembic/env.py reads the same settings as the app.)
docker-compose-prod.yml runs the pre-built image (kiertolainen/forte:latest)
with gunicorn -w 4 -k uvicorn.workers.UvicornWorker, against an external
Postgres (e.g. Neon) and Redis reachable via REDIS_URL. No local postgres/redis
services are started — production expects managed instances.
Base URL: http://<host>:8000
Health check. Returns the literal string "connected".
Create a user account.
Request body (schemas.User):
{
"username": "string",
"password": "string",
"email": "user@example.com"
}Response 201 (schemas.ReturnUser):
{ "username": "string", "response": "Account created, you are ready to fight" }Errors: 409 if a user with that username already exists.
OAuth2 password flow. Must be sent as form data, not JSON (OAuth2PasswordRequestForm
requires application/x-www-form-urlencoded, fields username and password).
Response 200 (schemas.JWTData), and sets the access_token cookie (see
Auth model & cookies):
{ "access_token": "<jwt>", "token_type": "bearer" }Errors: 403 if no user with that username exists, or if the password doesn't match.
Fetch a user by username.
Response 200 (schemas.ReturnUser):
{ "username": "string", "response": null }Errors: 404 if the username doesn't exist.
Returns the usernames currently tracked as online (backed by the Redis
online_users: hash — see Redis key reference).
Response 200:
{ "online_users": ["alice", "bob"] }Returns the members of a room and their role.
Response 200 (schemas.Roommembers):
{
"room_id": "room1",
"members": { "alice": "host", "bob": "member" }
}GET /ws
Auth is via the access_token cookie only (see above) — there is no way to
authenticate a websocket connection with an Authorization header in this
implementation. A missing/invalid token closes the handshake with WS code 1008
(policy violation).
On a successful connect, the server immediately sends the last 100 global messages:
{
"type": "message history",
"room_id": "global_broadcast",
"messages": [
{ "channel": "global_broadcast", "username": "alice", "data": "hi" }
]
}Every message must be JSON with a top-level "type" key, or the server replies
{"message": "Invalid data format"} and ignores it. Unrecognized type values get a
WebSocketResponse with status: "error", action: "data vlidation" [sic].
type |
Schema | Fields | Handler |
|---|---|---|---|
"global message" |
GlobalMessage |
type, message |
broadcast_global |
"create room" |
Datavalidate (+ derived Userdata) |
type, room_id |
create_room |
"join room" |
Datavalidate (+ derived Userdata) |
type, room_id |
connect_local |
"local message" |
MessageValidate (+ derived Userdata) |
type, room_id, message |
broadcast_local |
"disconnect Local" |
Datavalidate (+ derived Userdata) |
type, room_id |
disconnect_local |
"joinback rooms" |
— (no body validation) | type only |
get_connected_back_to_rooms |
Notes:
Datavalidateand its subclasses useextra="forbid"— unexpected extra fields reject the message with{"response": "invalid data format"}, for not allowing manual changes of role.- For every type except
"global message"and"joinback rooms", the server derives aUserdataobject as{"username": <from JWT>, "room_id": data["room_id"]}— the client never sends its own username, it's always taken from the authenticated session. "global message"example:{ "type": "global message", "message": "hello everyone" }"create room"/"join room"/"disconnect Local"example:{ "type": "create room", "room_id": "room1" }"local message"example:{ "type": "local message", "room_id": "room1", "message": "hey room" }"joinback rooms"example (used to reconnect a client to all rooms it was already a member of, e.g. after a page refresh):{ "type": "joinback rooms" }
Most actions reply with a WebSocketResponse:
{ "status": "ok" | "error", "detail": "readable detail", "action": "function_name" }action mirrors the request type (create_room, connect_local, disconnect_local,
broadcast_global, broadcast_local). status: "error" cases include: room already
exists (create_room), room doesn't exist / already a member (connect_local), and
not-a-member-of-room (broadcast_local) — these are returned as normal "ok"-shaped
errors rather than exceptions, so the connection stays open.
Broadcast fan-out (delivered to all relevant sockets, including the sender, via the Redis pub/sub listener) has a different shape:
{ "channel": "global_broadcast" | "<room_id>", "username": "alice", "message": "hi" }Presence updates (sent to every globally-connected socket when someone comes online or every connection for a user drops):
{ "channel": "online_users" | "offline_users", "username": "alice" }"joinback rooms" reply is a bare JSON array (not wrapped in WebSocketResponse) since its http function,
one entry per room the user belongs to:
[
{
"type": "message history",
"room_id": "room1",
"host": true,
"messages": [ { "channel": "room1", "username": "alice", "data": "hi" } ]
}
]Validation failures use one of two (inconsistent) shapes depending on which check failed:
{ "response": "invalid data format" }{ "message": "Invalid data format" }On WebSocketDisconnect, the server removes the socket from local + Redis state
(disconnect_global) and broadcasts "<username> left the chat" on the global
channel, but keep the data in redis, like room joined, host/members.
| Key | Type | Purpose |
|---|---|---|
online_users: |
hash | username -> connection count (reference-counted presence) |
global_broadcast |
list | last 100 global messages (also doubles as the pub/sub channel name) |
room:host:{room_id} |
string, TTL 24h | username of the room's host; existence = room exists |
room:members:{room_id} |
hash, TTL 24h | username -> role ("host"/"member") |
user:rooms:{username} |
set, TTL 24h | room IDs the user currently belongs to |
room:broadcast:{room_id} |
list + pub/sub channel | last 100 messages for that room |
Room-related keys expire after ROOM_TTL (86,400 seconds / 24h) of inactivity.