Private
Public Access
DEPLOYMENT.md: two spots (§7 backups, §9 known gaps) said "images" when they should say "images and file attachments" -- generic file attachments (#13) share the exact same no-backup-coverage and orphaned-upload gaps as images, but the wording was never updated when that feature shipped. ARCHITECTURE.md §9.2 and the tech-stack table described a materially different, outdated architecture: nginx running on the app server, reverse-proxying to gunicorn over a Unix socket. The actual setup (which DEPLOYMENT.md already correctly documents) has no nginx on the app server at all -- gunicorn binds a TCP port directly, and TLS/reverse-proxying is handled by an external, pre-existing Nginx Proxy Manager instance. Rewrote both to match reality. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
264 lines
11 KiB
Markdown
264 lines
11 KiB
Markdown
# Chat service — architecture document
|
|
|
|
## 1. Overview
|
|
|
|
A web-based team chat service, similar in spirit to Mattermost, with no threaded
|
|
conversations. Core features:
|
|
|
|
- Chat rooms that are either **open** (anyone can join) or **invite-only** (private)
|
|
- A **PWA** client — single codebase serves desktop, mobile web, and an installable
|
|
app experience
|
|
- **Push notifications** for offline/backgrounded users
|
|
- A **full admin portal** for site administration
|
|
- An **extension system** for bots and AI agents (webhooks, scoped API tokens,
|
|
live WebSocket access)
|
|
|
|
Deployment target: two plain Linux servers, no containers. One server runs the
|
|
database and Redis; the other runs the application and serves the frontend.
|
|
|
|
## 2. Tech stack
|
|
|
|
| Layer | Choice | Why |
|
|
|---|---|---|
|
|
| Backend | Python, FastAPI (async) | Async-native, fits many concurrent WebSocket connections without extra layers |
|
|
| ORM / migrations | SQLAlchemy 2.0 (async) + Alembic | Mature async ORM, explicit schema migrations |
|
|
| Database | PostgreSQL | Relational structure fits users/rooms/memberships/messages well |
|
|
| Cross-instance broadcast | Redis (pub/sub) | Lets multiple app server processes fan out messages to all connected clients |
|
|
| Push notifications | pywebpush + VAPID | Standard Web Push, works on Android and iOS 16.4+ (PWA must be installed to home screen on iOS) |
|
|
| Frontend | React + Vite, vite-plugin-pwa | Generates the manifest and service worker for install + push |
|
|
| Reverse proxy / TLS | External Nginx Proxy Manager (pre-existing infra, not deployed by this project) | Terminates TLS, forwards REST + WebSocket traffic to the app server's one port. The app itself serves the built static frontend directly — no separate static-asset server needed |
|
|
| Process management | systemd | No Docker — native to the target Linux distro, no extra install |
|
|
| Auth | Session cookies (httpOnly, secure) | Simplest to carry through a WebSocket handshake automatically |
|
|
|
|
## 3. System architecture
|
|
|
|
### 3.1 Core message flow
|
|
|
|
```
|
|
PWA client (browser + service worker)
|
|
| REST + WebSocket
|
|
v
|
|
FastAPI app server (behind an external reverse proxy / TLS terminator)
|
|
| | |
|
|
v v v
|
|
PostgreSQL Redis pub/sub Push service (pywebpush)
|
|
(persistence) (fan-out across (delivers to offline
|
|
app instances) clients via Web Push)
|
|
```
|
|
|
|
A message is persisted to Postgres, published to a Redis channel scoped to its
|
|
room, and every app server instance subscribed to that channel forwards it over
|
|
WebSocket to its own connected clients who are members. Members who are not
|
|
currently connected get a Web Push notification instead, looked up from their
|
|
stored push subscription.
|
|
|
|
Redis only matters once more than one app server process is running. A single
|
|
instance can skip it entirely and add it later without changing anything else.
|
|
|
|
### 3.2 Admin and extension layer
|
|
|
|
```
|
|
Admin portal Bots / AI agents
|
|
| |
|
|
v v
|
|
API gateway
|
|
(scoped tokens + admin role checks)
|
|
|
|
|
v
|
|
Core chat server
|
|
(rooms, messages, permissions)
|
|
|
|
|
v (WebSocket events, dashed = async)
|
|
back out to bots/agents
|
|
```
|
|
|
|
Admin portal and bots/agents are both just API consumers, differentiated by the
|
|
credentials they carry: session + `is_site_admin` flag for the portal, scoped API
|
|
tokens for bots. Bots can also hold a live WebSocket connection to receive room
|
|
events in real time and post message updates — the same mechanism a human client
|
|
uses.
|
|
|
|
## 4. Data model
|
|
|
|
```
|
|
users
|
|
id, username, email, password_hash, is_bot, is_site_admin, created_at
|
|
|
|
rooms
|
|
id, name, description, is_private, owner_id, created_at
|
|
|
|
room_memberships
|
|
room_id, user_id, role (owner | admin | member), joined_at
|
|
|
|
room_invites
|
|
id, room_id, invited_by, token, target_user_id or target_email,
|
|
expires_at, status (pending | accepted | revoked)
|
|
|
|
messages
|
|
id, room_id, user_id, content, created_at, edited_at, deleted_at
|
|
|
|
push_subscriptions
|
|
id, user_id, endpoint, p256dh_key, auth_key, created_at
|
|
|
|
api_tokens
|
|
id, owner_id (user or bot), token_hash, scopes[], last_used_at, created_at
|
|
|
|
webhooks_incoming
|
|
id, room_id, token, created_by, description
|
|
|
|
event_subscriptions
|
|
id, room_id (nullable = global), event_types[], target_url,
|
|
signing_secret, created_by
|
|
|
|
admin_audit_log
|
|
id, actor_id, action, target_type, target_id, metadata, created_at
|
|
```
|
|
|
|
## 5. Permission model
|
|
|
|
- **Room visibility**: `open` (any authenticated user can find and join) or
|
|
`private` (visible only to members, joinable only via invite).
|
|
- **Room roles**: `owner` (delete room, transfer ownership), `admin` (invite/remove
|
|
members, edit settings), `member` (post, leave).
|
|
- **Site-level**: `is_site_admin` on the user record, checked for every admin
|
|
portal route.
|
|
- Every room action is authorized server-side against `room_memberships` — never
|
|
trust a client's claim about its own role or membership.
|
|
|
|
## 6. Real-time and push notification flow
|
|
|
|
1. Client sends a message over its open WebSocket.
|
|
2. Server checks the sender is a member of the room, persists the message.
|
|
3. Server publishes the message to the room's Redis pub/sub channel.
|
|
4. Every app instance subscribed to that channel forwards it to its own connected
|
|
members over WebSocket.
|
|
5. For members with no active connection, the server looks up
|
|
`push_subscriptions` and sends a Web Push notification via `pywebpush`.
|
|
|
|
## 7. Extension system: bots and AI agents
|
|
|
|
Extensions run **outside** the server process and talk to it over the network —
|
|
no in-process plugin runtime, no sandboxing to build. This is deliberately the
|
|
lighter-weight option, and it matches how AI agents naturally integrate: as an
|
|
HTTP/WebSocket client.
|
|
|
|
- **Bot accounts**: a row in `users` with `is_bot = true`. Can be added to rooms
|
|
and post messages exactly like a human account.
|
|
- **Scoped API tokens**: e.g. `read:messages`, `write:messages`, `manage:rooms`,
|
|
issued per bot from the admin portal, hashed at rest, shown once at creation.
|
|
- **Incoming webhooks**: a room-scoped URL an external service can POST a message
|
|
to. No auth flow beyond the URL being a secret.
|
|
- **Outgoing webhooks / event subscriptions**: the server POSTs to a registered
|
|
URL when matching events happen, signed with `signing_secret` so the receiver
|
|
can verify authenticity.
|
|
- **Live WebSocket access for bots**: same connection type the PWA client uses,
|
|
authenticated with a bot token. Lets a bot or AI agent see messages as they
|
|
arrive and reply without polling.
|
|
- **Message update events**: beyond create/edit/delete, support patching an
|
|
existing message's content. This lets an AI agent post a placeholder and stream
|
|
tokens into it live, the same pattern Slack/Discord bots use.
|
|
|
|
Security notes: validate outgoing webhook target URLs to block requests into
|
|
internal network ranges (SSRF), rate-limit bot API calls the same as human ones,
|
|
and log bot actions to `admin_audit_log`.
|
|
|
|
## 8. Admin portal
|
|
|
|
Built as protected routes inside the same React PWA (`/admin/*`), gated by
|
|
`is_site_admin` on the session — no separate app or deployment to maintain.
|
|
|
|
Features:
|
|
- User management: list, deactivate, reset password, promote to site admin
|
|
- Room management: view all rooms (including private), transfer ownership,
|
|
force-archive
|
|
- Bot/integration management: create bots, generate/revoke tokens, set scopes,
|
|
view registered webhooks
|
|
- System settings: open vs invite-only registration, file size limits, branding
|
|
- Audit log viewer
|
|
|
|
For fast internal CRUD scaffolding on top of the SQLAlchemy models, consider
|
|
[SQLAdmin](https://aminalaee.dev/sqladmin/) mounted on an internal-only path —
|
|
useful for raw table management while custom logic (moderation, bot tokens,
|
|
audit views) gets built separately.
|
|
|
|
## 9. Deployment architecture — two Linux servers, no Docker
|
|
|
|
### 9.1 Data server
|
|
|
|
Runs PostgreSQL and Redis.
|
|
|
|
- Bind Postgres and Redis to the private network interface only, never `0.0.0.0`
|
|
on a public interface.
|
|
- Firewall (`ufw` or `iptables`): allow port 5432 (Postgres) and 6379 (Redis)
|
|
only from the app server's IP address.
|
|
- If the hosting provider doesn't offer a private network between the two
|
|
servers, put a WireGuard tunnel between them and bind services to the tunnel
|
|
interface instead of trusting a firewall rule alone over the public internet.
|
|
- Backups: nightly `pg_dump` via a cron job, rotated and shipped off-box.
|
|
|
|
### 9.2 App server
|
|
|
|
Runs the FastAPI app, which also serves the built PWA static files directly
|
|
(`backend/app/main.py` mounts `frontend/dist` alongside `/api` and `/ws`) —
|
|
no separate web server runs on this box.
|
|
|
|
- Python virtualenv, application installed via `pip install -e .` or similar.
|
|
- App run via Gunicorn with Uvicorn workers, one process per CPU core as a
|
|
starting point, managed by a systemd unit binding a plain TCP port:
|
|
|
|
```ini
|
|
# /etc/systemd/system/ds-chat.service
|
|
[Unit]
|
|
Description=DS Chat app server
|
|
After=network.target
|
|
|
|
[Service]
|
|
User=ds-chat
|
|
WorkingDirectory=/srv/ds-chat/backend
|
|
EnvironmentFile=/etc/ds-chat/env
|
|
ExecStart=/srv/ds-chat/backend/.venv/bin/gunicorn app.main:app \
|
|
-k uvicorn.workers.UvicornWorker \
|
|
--workers 4 \
|
|
--bind 0.0.0.0:8000
|
|
Restart=on-failure
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
- TLS termination and public-facing reverse proxying are **not** handled on
|
|
this box — they're handled by an existing, separate Nginx Proxy Manager
|
|
(NPM) instance elsewhere in the infrastructure, configured to forward
|
|
`chat.example.com` to this server's port 8000 with WebSocket support
|
|
enabled (required — without it `/ws/chat` can't upgrade). A firewall rule
|
|
(`ufw`) restricts port 8000 to NPM's address only — see `DEPLOYMENT.md`
|
|
§3g/§4 for the exact commands and NPM configuration steps.
|
|
- Secrets (database URL pointing at the data server's private IP, Redis URL,
|
|
VAPID keys, session secret) live in `/etc/ds-chat/env`, loaded via
|
|
`EnvironmentFile=`, never committed to the repository.
|
|
- Deploy process: `git pull`, install/update dependencies, `alembic upgrade
|
|
head`, build the frontend, `systemctl restart ds-chat` — automated end to
|
|
end by `deploy/upgrade.sh`.
|
|
- Logs: `journalctl -u ds-chat`, rotated by systemd/journald defaults.
|
|
|
|
## 10. Security considerations
|
|
|
|
- Server-side authorization on every room and message action — never trust
|
|
client-supplied role/membership claims.
|
|
- Database bound to the private network only, firewalled to the app server's IP.
|
|
- API tokens and webhook secrets hashed/stored securely, shown once at creation.
|
|
- Outgoing webhook URLs validated against internal IP ranges to prevent SSRF.
|
|
- Rate limiting on both human and bot API traffic.
|
|
- TLS everywhere in transit (Nginx-terminated for clients; a WireGuard tunnel or
|
|
equivalent for cross-server DB traffic if not on a trusted private network).
|
|
|
|
## 11. Phased build plan
|
|
|
|
1. Auth, room CRUD, open rooms, single-instance WebSocket messaging
|
|
2. Private rooms, invites, roles
|
|
3. PWA shell — manifest, service worker, offline caching
|
|
4. Push notification subscription + delivery
|
|
5. Redis pub/sub for horizontal scaling across app server instances
|
|
6. Admin portal
|
|
7. Bot/extension system: tokens, webhooks, bot WebSocket access, message updates
|