This document explains in detail how the project is organized: the architecture,
every folder and file, what each file is responsible for, and its main functions
and classes. It is written for developers who want to understand the codebase or
run the project on their own. If anything is unclear, start here, then look at
README.md (quick start) and .env.example (configuration).
bash.social is a browser-based "messenger terminal". Instead of buttons and
a mouse there is a command line: the user types /register, /msg,
/group create and so on, and the server replies with terminal-style lines.
Messages arrive in real time over WebSocket.
Key features:
- Two-step login:
/login <nick>then/password <password>. - Friend requests with confirmation (
/adduserthen/accept//reject). - Private and group chats with unread counters and live notifications.
- Messages are encrypted at rest in the database (Fernet).
- Configurable terminal themes (17 themes with CRT effects).
- Real security: Argon2, rate limits, security headers, WebSocket origin checks, input sanitization.
- No telemetry and no external requests; privacy is a priority.
Stack: Python 3.12 · FastAPI · SQLAlchemy 2 (async) · PostgreSQL · Redis · Docker.
The project is a monolith in a single Docker image (a FastAPI application). Data is split between PostgreSQL and Redis by storage type:
- PostgreSQL holds durable data: users, friendships, groups, members, messages.
- Redis holds short-lived data: sessions, rate limits, the currently open chat, command history, presence (who is online), pending confirmations.
General data flow:
Browser (static/)
│ /api/command (HTTP) or /ws (WebSocket)
▼
app/api/ (http.py, ws.py) — entry points, builds the TermContext
│ dispatch(ctx, raw)
▼
app/cli/dispatcher.py — routing: command / plain text / confirmation
│ handler call
▼
app/cli/commands/*.py — command handlers (dialogue logic only)
│
├─► app/services/*.py — business logic and DB access (framework-free)
├─► app/security/*.py — crypto, sanitization, headers
└─► app/utils/*.py — small helpers (output lines, validation, rate limit)
│
▼
response: a dict {"lines": [...], "prompt", "chat", ...}
│
▼
The browser renders the lines via terminal.js (textContent — no XSS vector)
The logic is split into layers with explicit boundaries:
- The command layer (
app/cli/commands) only knows about the "dialogue": collects arguments, calls a service, returns lines to display. - The service layer (
app/services) is independent of FastAPI and the CLI: it takesdb(a SQLAlchemy session),redisandmanageras explicit arguments and does the actual work with the data. - The security layer (
app/security) isolates everything related to cryptography, HTTP headers and input validation. - The utilities (
app/utils) are small reusable helpers.
bash.social/
├── app/ # the whole application
│ ├── api/ # HTTP and WebSocket entry points
│ │ ├── http.py # REST: /api/me, /api/command, /api/commands
│ │ └── ws.py # WebSocket /ws: real time
│ ├── cli/ # the server-side "command line"
│ │ ├── commands/ # command handlers (registered via decorator)
│ │ ├── confirm.py # action confirmation mechanism (/y /n)
│ │ ├── context.py # TermContext and the prompt
│ │ ├── dispatcher.py # the main router for every typed line
│ │ ├── helpers.py # usage_hint, convert_cli
│ │ ├── parser.py # splits a line into a command and arguments
│ │ ├── registry.py # command registry (@command)
│ │ └── theme_meta.py # terminal theme catalogue
│ ├── models/ # SQLAlchemy models (tables)
│ ├── security/ # security: crypto, headers, sanitization, WS
│ ├── services/ # business logic (DB and Redis access)
│ ├── static/ # frontend: HTML, CSS, JS
│ ├── tests/ # automated tests
│ ├── config.py # settings (BS_ prefix)
│ ├── database.py # SQLAlchemy engine and table creation
│ ├── exceptions.py # the CliError domain error
│ ├── main.py # FastAPI entry point
│ ├── realtime.py # WebSocket connection manager
│ └── redis_client.py # async Redis client
├── .env.example # configuration template (all BS_ variables)
├── Dockerfile # image build
├── docker-compose.yml # the stack: app + db + redis
├── Makefile # commands: compose, run, test, logs, down
├── pyproject.toml # dependencies and pytest config
└── README.md # quick start and the command list
The file uvicorn launches (uvicorn app.main:app).
lifespan— an async context: on startup it callsinit_db()(table creation), on shutdown it disposes the DB engine.app = FastAPI(...)— the application instance.app.add_middleware(SecurityHeadersMiddleware)— global security headers.app.mount("/static", ...)— serves static assets (CSS/JS).app.include_router(http_router)andapp.include_router(ws_router).GET /— returnsindex.html(the terminal itself).
The Settings class (pydantic-settings). It reads environment variables with
the BS_ prefix and the .env file. get_settings() with lru_cache returns
a single settings object for the whole application.
What can be configured (see section 10 for details):
- connections:
BS_DATABASE_URL,BS_REDIS_URL; - sessions:
BS_SESSION_TTL,BS_MAX_SESSIONS; - rate limits:
BS_REGISTER_*,BS_LOGIN_*,BS_PASSWORD_*,BS_MSG_*,BS_CMD_*,BS_WS_*; - input limits:
BS_MAX_INPUT,BS_MAX_MSG,BS_MAX_WS_MESSAGE,BS_PAGE_SIZE,BS_HIST_LEN; - language, theme, registration:
BS_DEFAULT_LANG,BS_DEFAULT_THEME,BS_REGISTER_ENABLED; - name and password bounds:
BS_NICK_MIN/BS_NICK_MAX,BS_PASSWORD_MIN/BS_PASSWORD_MAX,BS_GROUP_NAME_MAX; - security:
BS_ALLOWED_ORIGINS,BS_ENCRYPTION_KEY,BS_CONFIRM_TTL.
Base— the SQLAlchemy declarative base every model inherits from.engine— the async engine (asyncpg for PostgreSQL, aiosqlite for tests).session_factory— the async session factory.init_db()—Base.metadata.create_allon startup. It currently replaces migrations; Alembic is planned.
redis_client — an async Redis client (redis.asyncio) with
decode_responses=True. Used by all services for sessions, rate limits,
presence and so on.
The ConnectionManager class maps user_id → set of WebSockets:
connect(uid, ws)/disconnect(uid, ws)— attach and detach a socket;is_online(uid)— presence check;send_to_user(uid, payload)— push to one user on all their sockets;send_to_members(ids, skip_id, payload)— push to a group except the sender.
manager is a process-wide singleton. Note: this is an in-memory manager, it
only works within a single uvicorn process (for multiple processes you would
need Redis Pub/Sub).
CliError(*items) — the error of the service and command layers. items is a
mix of plain strings and ready-made output line dicts. The dispatcher turns it
into red lines for the user; internals never leak out.
GET /api/me— session restore: given a token (Authorization: Beareror?token=) it returns the profile, prompt, open chat and theme.GET /api/commands— the list of all commands (name, aliases, usage, help) for the client's hint bar and tab completion.POST /api/command— a fallback transport without WebSocket: body{"input": "...", "token": "..."}; builds aTermContext, callsdispatch()and returns the payload. Errors are logged (logger.exception) without showing internals to the user.
Helpers here: _ip_of(request) and _bearer_token(request).
/ws — the main real-time channel. What happens to each socket:
- Origin check (
origin_is_allowed) and a connection rate limit. - A read loop. There are two message types:
{"type": "auth", "token": ...}— bind the socket to a user (registered inmanager, added topresence:online);{"type": "cmd", "input": ..., "token": ...}— a command:dispatch()is called, the response goes viasend_json.
- The raw message size is checked before JSON parsing; the limit is
settings.max_ws_message. - On socket close the user is detached and removed from presence.
Helpers: _bind_socket (auth + presence), _drop_presence, _client_ip.
split_command(raw)— returns(name, rest). A line starting with/is a command, anything else is "plain text" (a chat message).split_args(rest)— splits into arguments viashlex.split(respects quotes).strip_quotes(text)— removes one wrapping pair of quotes.parse_page(token)— safe page-number parsing.
Command— a dataclass:name,handler,usage,help,group,aliases,auth,public,self_help.COMMANDS— a dictname → Command(including aliases).REGISTERED— a list in registration order (for/helpand/api/commands).command(...)— the decorator that registers a handler. This is the main extension mechanism: to add a command, just wrap a function.
build_prompt(nick, chat)— the prompt string, e.g.alex@bash.social:team$orguest@bash.social:~$.load_chat(redis, uid)/save_chat(redis, uid, chat)— which chat the user has open (stored in Redis underchat:user:{uid}).TermContext— a dataclass alive for the duration of one command:redis,db_factory,manager,ip,token,user,chat,cmd,theme,lang,session_payload,logout. Theopen_db()method is shorthand for getting a DB session.
dispatch(ctx, raw) is the entry point for any typed line:
- Restores the user from the token (
_load_user) and loads the open chat. - Writes the command to the server history (only "historable" commands —
private
/password,/msg,/contactand plain text are never recorded). - Applies the command rate limit.
- Routing:
- plain text →
send_text(a message in the active chat); /yand/n→_handle_confirm(action confirmation);- a command from
COMMANDS→ its handler; - an unknown command → a polite error.
- plain text →
- Wraps the result into a payload: output lines, prompt, chat, theme, language,
session and a
logoutflag.
Also here: _help_of (generic /command help), _convert_error
(CliError → lines) and out() (payload assembly for the client).
A mechanism for dangerous actions (for example /delete_account):
request(redis, uid, action, **extra)— stores the pending action in Redis underconfirm:{uid}with aconfirm_ttlTTL.peek(redis, uid)— check whether an action is pending.resolve(redis, uid)— fetch and delete it (the action runs only once).
usage_hint(ctx)— the standard "missing arguments" reply with usage.convert_cli(exc)— turns aCliErrorinto a list of output lines.
THEMES is a list of (name, title, description) tuples for 17 themes
(kali, macos, green, amber, crt, dos, matrix, retro, hacker, paper, solarized,
phantom, vscode, vim, xcode, tokyonight, dracula). theme_exists(name) and
theme_desc(name) check and describe a theme.
Each file registers its commands with the @command(...) decorator.
The shared contract is async def handler(ctx, args) -> list (a list of line
dicts). If a command requires auth by default (auth=True), the dispatcher
checks ctx.user itself.
auth.py — account:
cmd_register—/register <nick> <password>(auto-login).cmd_login/cmd_password— two-step login.cmd_logout— end the session (blocked inside a chat).cmd_whoami— profile: friends, unread, theme, policies.cmd_whois— the public card of another user.
friends.py — search and friends:
cmd_adduser— send a friend request + a live notification to the target.cmd_accept— accept a request + a notification to the sender.cmd_reject(aliasdeny) — decline a request + a notification.cmd_requests— incoming and outgoing requests.cmd_deluser— remove a friend / cancel your own request.cmd_find— search by part of a nick.cmd_ls— universal lists:user,my_users(friends),groups,unread. Helpers:ls_users,ls_friends,ls_groups,ls_unread.
chat.py — messages:
send_text(ctx, text)— the common sender for plain text and/msg(limit checks, sanitization, encryption, push to the receiver).cmd_contact(aliasopen) — open a private chat and show the history.cmd_msg— send a message in the current chat.cmd_leave— leave a chat + notify the peer/members.
groups.py — groups:
cmd_group— subcommands:help,create,join,invite,leave,kick,rename,members,delete; a group code opens it directly.cmd_mk_group— shorthand for creating.- Internals:
group_create,group_enter,group_invite,group_leave,group_delete,group_kick,group_rename,group_members_list,group_help_lines.
settings.py — settings:
cmd_settings— show settings; subcommandsset,security,help.show_settings— output of the current values.settings_help— the reference table.
themes.py — themes:
cmd_theme— current theme,help/ls(the list), switching themes.
account.py — security and language:
cmd_delete_account— a red warning + confirmation via/yor/n.cmd_security(aliassec) — encryption, password, sessions, language.cmd_language(aliaslang) — switch the UI language (ru/en).
system.py — system commands:
cmd_help— the command list grouped by section, or help for one command.cmd_clear(aliascls) — clear the screen.cmd_history— recent commands from the server history.GROUP_ORDER— the section order in/help.
__init__.py — imports every command submodule (that is the only way the
decorators fire and commands reach the registry).
Services are unaware of FastAPI and the CLI — they take db, redis and
manager as explicit arguments. This keeps them easy to test.
register_user(db, redis, nick, password, ip)— validation, rate limit, nick uniqueness, Argon2 hashing, session creation.start_login(redis, ip, raw_nick)— step one: stores the nick in Redis (login:{ip}) with a TTL and applies a rate limit.finish_login(db, redis, ip, password)— step two: password verification, session creation, removal of the pending login._reject_nick_in_password— the nick must not appear in the password.
Keys: sess:{token} → user_id and user_sess:{uid} → a list of tokens.
create_session(redis, user)— a new random token, a TTL write, an index entry and trimming of excess sessions (_trim_sessions)._trim_sessions— keeps at mostmax_sessionssessions; the oldest is evicted so a stolen token cannot live forever.destroy_session(redis, token)— removes the token and its index slot.session_user(db, redis, token)— restore the user from a token.
get_user_by_nick(db, nick)— exact nick lookup.list_users(db, page)/search_users(db, term, page)— listings and search with pagination.pages_for(total)— the number of pages.
The friendship model is one row per pair. Accepted links are symmetric and
stored canonically (min, max); requests are stored as (sender, target) so
the direction is known. Statuses: pending (request) and accepted (friends).
pair_row(db, a, b)— the link row in either direction.are_friends(db, a, b)— true only if the status isaccepted.send_request(db, me, nick)— create a request (with checks: yourself, already friends, a request already exists).accept_request(db, me, nick)/reject_request(db, me, nick)— accept or decline an incoming request.remove_friend(db, me, nick)— remove a friend or cancel your own request.friend_ids_of,friends_count,list_friends— friends with the statusacceptedonly.incoming_requests,outgoing_requests,pending_incoming_count— requests for/requests.
send_dm(db, manager, sender, receiver, text)— a private message: receiver policy check, sanitization, encryption, persistence, live push.send_group_message(db, manager, sender, group_id, text)— a group message plus a push to all members except the sender.message_text(msg)— decrypts text for display.fetch_dm_history/fetch_group_history— history (last N).mark_dm_read/mark_group_read— clearing unread markers.unread_per_sender,unread_dm_total,group_member_ids— counters.- Helpers:
peer_key(a, b)— the canonical dm key"min:max",fmt_clock(dt)/fmt_date(dt)— time formatting.
create_group— creation with a unique slug (the join code).join_group— join by code.invite_group— only the creator may invite, respectingallow_invites.delete_group— destroys the room together with messages and members.leave_group,kick_group,rename_group— the other operations.role_of— the member role (owner/admin/member).group_by_slug/group_by_id— lookup.group_members,group_member_count,my_groups— listings.unread_group/mark_group_read— unread via thelast_read_idcursor.
get_settings(user)— a dict of the current values.set_setting(db, user, key, value)— a validated update:allow_messages(everyone/friends/nobody),allow_invites(on/off),lang(ru/en). An unknown key raisesCliError.
delete_account(db, redis, user)— complete removal: private messages in both directions, own groups (with messages and members), membership in other people's groups, friendships and the user row itself; in Redis — chat state, history, confirmations, presence and every session.
Models inherit from app.database.Base. Importing all models in
models/__init__.py ensures create_all sees them all.
nick— a unique nick (5–15 chars).password_hash— an Argon2 hash (never leaves the service layer).theme— the terminal theme name.lang— the UI language (ru/en).allow_messages— the dm policy:everyone/friends/nobody.allow_invites— whether the user can be invited to groups.created_at— registration date.
user_id/friend_id— the user pair.status—pendingoraccepted.- Pair uniqueness (
uq_friendship_pair).
Group (table groups):
name— the group name (up to 40 chars).slug— the unique invite code (6 base36 chars).owner_id— the creator.
GroupMember (table group_members):
group_id/user_id— a member.role—owner/admin/member.last_read_id— the read cursor for the unread counter.
kind—dmorgroup.sender_id— the sender.peer_key— the canonical dm key"min:max"(for dm).receiver_id— the receiver (for dm).group_id— the group (for group).text— the encrypted message text.created_at— the timestamp.read_at— the read marker (NULL = unread).- Indexes:
ix_msg_peer_time,ix_msg_group_time.
hash_password/verify_password— Argon2 (time_cost=3,memory_cost=65536,parallelism=4; never lower these).new_session_token— a random session token (secrets.token_urlsafe(32)).new_slug— a group code (base36).encrypt_text/decrypt_text— Fernet (AES-128-CBC + HMAC). The key is a SHA-256 digest ofBS_ENCRYPTION_KEY; if no key is set, a dev fallback is used.decrypt_textreturns the string as-is on failure so legacy unencrypted rows do not turn into garbage.
SecurityHeadersMiddleware (Starlette) adds to every response: CSP (strict
'self'), X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Permissions-Policy, COOP/CORP, and HSTS when the request came over HTTPS.
sanitize_text(text) strips control characters (keeping \n and \t) so
terminal escape sequences cannot be smuggled into chat output.
origin_is_allowed(websocket, allowed_origins)— Origin check: by default the same host plus localhost/127.0.0.1; a custom allow-list is supported.payload_too_big(raw, limit)— size check before JSON parsing.
Re-exports everything needed (hashes, tokens, encryption, middleware, sanitization, WS checks) so outside code does not depend on internal paths.
Every command returns a list of such dicts and the client renders them.
Styles: plain, ok, err, warn, accent, dim, head, banner,
clear. For multi-color lines use seg(style, text) and mixed(*segs)
(for example "nick: text time").
check(redis, key, limit, window) — a fixed window: increment the counter,
set a TTL on the first call. Returns True if the limit is not exceeded.
reset(redis, key) — reset.
normalize_nick/validate_nick— a nick: 5–15 chars, latin/digits/_.validate_password— a password of 8 to 128 characters.
Terminal markup: a "window" with a title bar, an output area, an input row, a
hint bar, a status bar (WebSocket status, theme, clock) and CRT effect layers.
It loads terminal.css, themes.css, effects.js, terminal.js.
The CSS cache is busted with ?v=N — bump it whenever you edit the CSS.
Base terminal layout: window, lines, style colors (plain, err, ok, dim,
accent, notice, echo), and the data-theme attribute used to pick a
palette.
Palettes for all 17 themes (keyed by data-theme="<name>") and the visual
effects (scanlines, vignette, glass, flicker, noise).
The main client:
state— token, theme, nick, prompt, open chat, history;init— session restore (/api/me), command list load (/api/commands), WebSocket connect, clock start;connect/scheduleReconnect— WebSocket with auto-reconnect;send— sending: over WS, or viaPOST /api/commandwhen the socket is down;handle— routing inbound messages:auth_ok,out,msg,notice,err;handleOut— applies a command payload: session, logout, theme, prompt, chat, output lines;handleMsg— a live message: printed inline when viewing that chat, otherwise a notification with a reply hint;handleNotice— notifications: friend request, accept/decline, chat leave;submit/onKey/navHist/complete— input, history (7 commands in sessionStorage), tab completion;PRIVATE_CMDS—/password,/msg,/contactare never remembered;/passwordis masked as******;applyTheme— applies the theme and its effects.
Per-theme CRT effects: scanlines, vignette, convex glass, flicker, noise
(drawNoise), matrix rain (drawMatrix). Works through canvas and CSS
classes; activates the right set via applyThemeFx(name).
Run with make test or python -m pytest -q. Tests use the env fixture,
which spins up an in-memory SQLite and stubs for Redis and the socket manager.
FakeRedis— an in-memory Redis stub with only the commands that are really used (get/set/delete/incr/expire, lists, sets).StubManager— instead of real sockets it records pushes inmanager.sentas("user", uid, payload)or("group", member_ids, skip_id, payload).env— the fixture: SQLite engine, session factory,make_ctx(ip, token)for building command contexts.
Flow tests through dispatch(): registration, login/logout, friends with
requests, private and group chats, message policies, settings, /ls.
Checks live notifications: friend requests and their accept/decline, chat leave notices.
Security: nick-in-password rejection, session cap, history privacy, text sanitization, security headers and WebSocket checks.
All variables have the BS_ prefix (see .env.example). Defaults live in
app/config.py, so almost nothing is required to run — but in production you
must set BS_ENCRYPTION_KEY and BS_DATABASE_URL.
Three services:
db— PostgreSQL 16 (postgres:16-alpine), user/password/databasebs. Host port 5433 → container 5432 (host port 5432 is taken by the system PostgreSQL — leave it alone). Thepgdatavolume stores the data.redis— Redis 7 withappendonly yes(disk persistence).app— built fromDockerfile, env from.env, waits for db and redis via healthchecks. Port 8000.
Python 3.12-slim. First installs dependencies (pip install .), then copies
the app/ source — so the layer cache survives source edits. The run command
is uvicorn app.main:app --host 0.0.0.0 --port 8000.
make compose— build and start the stack;make setup— local install with dev dependencies;make run— local uvicorn;make test— run the tests;make logs/make down— logs and teardown.
| Key | Format | Purpose |
|---|---|---|
sess:{token} |
string | session: token → user_id (TTL session_ttl) |
user_sess:{uid} |
list | the user's active-session index |
login:{ip} |
string | a pending second login step (/login → /password) |
rl:* |
counter | rate limits: rl:register:{ip}, rl:login:{ip}, rl:pass:{ip}, rl:msg:{uid}, rl:cmd:{uid}, rl:ws:{ip} |
chat:user:{uid} |
JSON | the user's open chat |
hist:{uid} |
list | the server command history (non-private) |
presence:online |
set | who is online (user_id) |
confirm:{uid} |
JSON | a pending action confirmation (/delete_account) |
- Create a file in
app/cli/commands/(or add a function to a fitting one). - Wrap the handler with the
@command(...)decorator:@command( "mycmd", usage="/mycmd <argument>", help="A short description.", group="SECTION NAME", ) async def cmd_mycmd(ctx, args): if not args: return usage_hint(ctx) # business logic, work with ctx.open_db() and ctx.redis return [ok("Done.")]
- Import the module in
app/cli/commands/__init__.pyso the decorator fires. - The command automatically appears in
/helpand/api/commands(client tab completion). - If the command has many subcommands, set
self_help=Trueand handlehelpyourself, like/themeand/groupdo.
If the command mutates data, do not write SQL in the command: put a function
into the appropriate service under app/services/.
cp .env.example .env # configure if needed
make compose # docker compose up -d --build
# open http://localhost:8000Without Docker:
make setup # pip install -e ".[dev]"
redis-server --daemonize yes
BS_DATABASE_URL="sqlite+aiosqlite:///./dev.db" make runTests:
make test