Internal enterprise chat for rhythmrx.ai — 1:1 & group messaging, reactions, replies, image/video/audio/document attachments, voice + video + group calls with screen share, a super-admin portal, org/department management, and strict multi-tenant isolation.
Rebuilt from the RxHivexx prototype per docs/RXHIVE_HANDOFF.md:
PostgreSQL + SQLAlchemy 2.0 async, Redis pub/sub realtime (horizontally
scalable), LiveKit SFU calling, S3-compatible object storage, httpOnly-cookie
auth with revocable refresh tokens.
rxhive/
├─ backend/ FastAPI (Python 3.12) — runs independently
├─ frontend/ React 19 + Vite — runs independently
├─ ios/ SwiftUI iOS app (Xcode 16+) — same backend, no mobile API
├─ infra/ docker-compose, Caddy, LiveKit config
└─ docs/ handoff spec + API reference
backend/, frontend/ and ios/ are fully self-contained: they share no code
and talk only over the documented HTTP/WebSocket API, so each can be built,
tested, and deployed on its own.
Web (frontend/) |
iOS (ios/) |
|
|---|---|---|
| Super admin | yes — the only place the admin portal exists | never |
Org admin (admin) |
yes | only if granted |
| Member | yes | only if granted |
Mobile access is a per-user grant issued by a super admin, not a role. A member or org admin cannot sign in to the iOS app until someone approves their account individually, at Admin → Users in the web portal (inline per-row toggle, edit-drawer control, a field on user creation, bulk grant/revoke, and an Approved / Not-approved filter).
Enforced in backend/app/api/auth.py:_assert_mobile_allowed, re-checked on every
request via a signed client claim and again on refresh — so revoking access ends
the phone's session immediately. Revoking does not end that user's web session:
refresh_tokens.client records which client opened each session, and only mobile
ones are revoked. Both refusals are 403 with a user-facing sentence, never 401,
so "not approved yet" can never be mistaken for "wrong password".
Covered by backend/tests/test_mobile_access.py.
See ios/README.md for the app itself.
cd infra
cp .env.example .env # then edit every secret
docker compose up --build # Postgres, Redis, MinIO, LiveKit, API, web, CaddyOpen http://localhost. The API runs migrations and seeds the super-admin
(RXHIVE_SEED_SUPERADMIN_EMAIL / _PASSWORD) on first boot. Sign in, then
create organizations, departments, and users from the admin portal.
LiveKit SFU — required for voice and video calls. Nothing else needs it, so
it is easy to forget; without it every call fails at the moment of connecting.
Its key/secret must match the API's RXHIVE_LIVEKIT_API_KEY / _SECRET:
LIVEKIT_KEYS="devkey: devsecret-at-least-32-characters-long" \
livekit-server --dev # ws://localhost:7880Backend (needs Postgres + Redis; MinIO for attachments; LiveKit for calls):
cd backend
python3.12 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
cp .env.example .env
.venv/bin/alembic upgrade head
.venv/bin/python -m app.seed
.venv/bin/uvicorn app.main:app --reloadConfirm the whole stack, calls included, in one request:
curl -s localhost:8000/api/health
# {"status":"healthy", "database":"connected", "redis":"connected",
# "livekit":"connected", "calls_available":true, ...}livekit is reported separately from status on purpose: messaging works
without the SFU, so a stopped LiveKit leaves the API healthy with
"livekit":"unreachable" and "calls_available":false.
Frontend (proxies /api → localhost:8000 in dev):
cd frontend
npm install
cp .env.example .env
npm run dev # http://localhost:5173# Backend: pytest against a real Postgres + Redis (rxhive_test DB)
cd backend && .venv/bin/pytest -q
# Frontend: Playwright E2E — boots the web server, waits for a healthy API
cd frontend && npm run test:e2e
# Against an already-running stack (faster local loop):
E2E_NO_SERVER=1 npm run test:e2eThe backend suite includes an explicit tenant-isolation gate proving org A can never read org B (messages, reactions, forwards, search, admin portals, calls, attachments).
The E2E suite asserts cross-user delivery lands within 5 seconds — a budget
the 15s conversation poll cannot satisfy, so a broken WebSocket fails the test
instead of silently degrading to polling. tests/global-setup.js fails loudly
if the API isn't healthy, rather than letting a dead stack surface as opaque
locator timeouts.
All E2E traffic shares one source IP, so raise the login budget for those runs:
RXHIVE_RATE_LIMIT_LOGIN=200 (rate limits are settings-tunable per scope).
See docs/CALLS.md for the full picture — architecture, the wire
contract every client implements, and a gap analysis of the six distinct causes
that used to present as "calls are unreliable". The short version for an operator:
Symptom — the call rings, the other side answers, then it drops or stays silent/black. Messaging is unaffected.
Check, in this order:
curl -s localhost:8000/api/health→ is"livekit":"connected"?unreachable— livekit-server isn't running (or isn't listening where the API expects). Start it with the command above.not_configured—RXHIVE_LIVEKIT_URLis unset, or it's a browser-only path like/livekit; setRXHIVE_LIVEKIT_HEALTH_URLto the server-side address (docker compose already does).
- Read the toast in the browser — each cause has its own message:
Toast Cause Call server unreachable — check that LiveKit is running SFU down/unreachable from the browser Microphone blocked. Allow microphone access… the browser denied mic permission Your microphone is in use by another app another app holds the device Camera blocked — continuing with audio only camera failed; the call is live, audio-only That call is no longer active the call ended before this side joined Connection lost — reconnecting… a link dropped; the call is being recovered, not over Poor internet connection either end's uplink is graded poor by the SFU - Open the browser console: every join failure logs
[call:<context>] join failed (<reason>)with the underlying LiveKit orgetUserMediaerror, and every signal is logged as[call] -> sent …/[call] joining SFU …so a whole call can be reconstructed from one console. - Still failing? The SFU key/secret must match the API's exactly
(
LIVEKIT_KEYS="<key>: <secret>"vsRXHIVE_LIVEKIT_API_KEY/_SECRET) — a mismatch surfaces as the SFU rejecting the token, not as a network error. In production also confirm the UDP media range is open (see below).
Symptom — the call ends by itself after a network blip, or a long call drops for no reason.
Grep the API log for the lifecycle, which names every decision:
grep -E 'call\.(initiated|accepted|link_down|link_up|grace_expired|ring_timeout|ended)' api.logA call.link_down with no matching call.link_up inside
RECONNECT_GRACE_SECONDS (40s) is a client that never came back — look at that
client, not the server. A call.grace_expired immediately after a
call.link_down means the reconnect itself is failing.
Symptom — an iOS user never receives calls unless the app is open.
Expected, and documented: waking a suspended iOS app for a call needs an APNs
VoIP push, which this backend cannot send (push_subscriptions holds Web Push
endpoints only). Web clients are woken. See
§6 of docs/CALLS.md
for what closing that would take. The ring window is 45 seconds and the ring is
re-delivered the moment the app opens, so a call remains answerable for its
whole window rather than being lost.
- Put the whole thing behind the bundled Caddy (
infra/Caddyfile) or your own TLS-terminating proxy; setSITE_ADDRESSto your domain for automatic HTTPS andRXHIVE_COOKIE_SECURE=true. - Generate a real JWT secret (
openssl rand -hex 32→RXHIVE_SECRET_KEY) and a LiveKit key/secret. The API refuses to boot in production with the dev secret. - Web Push: generate a VAPID keypair with
python -m app.tools.vapidand set the threeRXHIVE_VAPID_*vars. - LiveKit needs its media ports reachable by clients; set
use_external_ipand open the UDP range ininfra/livekit.yaml/ your firewall. - LiveKit posts room events back to the API (
webhook:ininfra/livekit*.yaml→POST /api/livekit/webhook). This is what finalizes a call nobody explicitly ended: without it, a client killed mid-call leavescall_history.status'connected'forever.webhook.api_keymust equalLIVEKIT_API_KEY; those YAMLs are in git and cannot read your.env, so changing one means changing the other. A mismatch is visible in the API log asRejected LiveKit webhook: verification failedwhile calls otherwise work. - Run multiple API workers freely — all shared state (sessions, presence, realtime fan-out, call state) lives in Postgres and Redis.
See docs/DEPLOYMENT.md for the full production checklist
and docs/API.md for the endpoint reference.