Source:
README.md
Multi-tenant shared inbox SaaS for WhatsApp (and future channels).
apps/
api/ FastAPI backend (source of truth, REST, Socket.IO, queue producer)
web/ Next.js 14 frontend
baileys-connector/ Node.js Baileys WhatsApp Web adapter (disposable)
infra/
docker/ Docker Compose for local Postgres / Redis / MinIO
docs/ Architecture / API / DB docs
┌────────────┐ cookie JWT ┌──────────────────┐
│ apps/web │ ───────────────▶ │ apps/api │
│ Next.js │ ◀── Socket.IO ── │ FastAPI │
└────────────┘ │ + python-socketio
└──────────────────┘
│ ▲
│ │ HMAC HTTP
▼ │ (X-Replybox-Sig)
┌─────┐ ┌─────────────────────┐
│Redis│ ◀────│ apps/baileys-connector
│ │BullMQ│ Node + Baileys │
└─────┘ └─────────────────────┘
┌─────────┐ ▲
│MinIO/S3 │ ◀───────┘ AES-256-GCM
│ │ session + media blobs
└─────────┘
┌─────────┐
│Postgres │ single source of truth
└─────────┘
whatsapp queue (the Python bullmq producer is wire-compatible with the Node worker). The disciplinary bridge has two narrow live probes over private HMAC HTTP: groupMetadata() and onWhatsApp(); they never mutate WhatsApp, though the roster probe may refresh the connector's in-memory LID map.POST /internal/v1/.... Signature is HMAC-SHA256 over {ts}\n{METHOD}\n{path}\n{body}, sent as X-Replybox-Timestamp + X-Replybox-Signature. Verifier: apps/api/app/security/hmac.py; signer: apps/baileys-connector/src/internal/api-client.ts.org:{id}).organization_id; user-facing public endpoints enforce membership. The pinned bearer-authenticated disciplinary provider routes are the documented server-to-server exception and expose no inbox API.owner, admin, agent, viewer) cloned per org on signup. System roles are read-only in the UI; custom roles are editable.require_permission().apps/api/app/seeds/permissions.py.apps/web/lib/api.ts.apps/api/app/realtime/sio.py.disconnected → connecting → awaiting_qr → connected | failed | logged_out
Pairing is QR-only; pairing-code flow is deferred.
CHANNEL_SESSION_KEY → uploaded as a single S3/MinIO blob at channels/{id}/auth.bin.MEDIA_OBJECT_KEY (same [iv(12) | tag(16) | ciphertext] layout) at channels/{id}/media/{message_id}. Two distinct keys mean leaking one key doesn't compromise the other domain.Queue name: whatsapp. Job types:
connect, disconnect, send, send_media, send_location, download_media, resync_history, mark_read, group_subject_update, group_leave, send_reaction.
Idempotency keys:
send:{message_id} — shared across text/media/location (a Message row is mutually exclusive across the three send shapes)download:{message_id}mark_read:{conversation_id}:{last_message_id}group_subject:{group_jid}group_leave:{group_jid}react:{channel_id}:{target_provider_message_id}Send-job guard: before calling sock.sendMessage, the worker checks Redis key send:done:{message_id} → cached provider_message_id (24h TTL). On BullMQ retry of a job whose status webhook 5xx'd, the cache hit re-posts the cached id instead of re-shipping the message, preventing duplicate WhatsApp deliveries.
Events emitted on org:{id}: channel.qr, channel.status, channel.sync, message.created (re-emitted on every outbound status transition and on media_status flips), conversation.updated. Typed emitters: apps/api/app/realtime/emit.py. The web client folds these into React Query caches — do not trigger refetches on realtime events.
Idempotent on (channel_id, provider_message_id). The connector filters broadcasts and protocol/poll updates before forwarding; reactions and groups are first-class supported. Mapper: apps/baileys-connector/src/whatsapp/messages.ts.
See docs/plan.md and CLAUDE.md for the full phased roadmap and contributor conventions.
# 1. Start infra
docker compose -f infra/docker/compose.yaml up -d
# 2. API
cd apps/api
python -m venv .venv && source .venv/bin/activate
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload --port 8000
# 3. Web
cd apps/web
pnpm install
pnpm dev
# 4. Baileys connector
cd apps/baileys-connector
pnpm install
pnpm dev
Or use the convenience launcher:
./dev.sh
pgweb is wired into the compose stack behind the tools
profile, so it never starts with normal infra. It reads its connections from
infra/docker/pgweb/bookmarks/*.toml.
# 1. Infra must be up (pgweb connects to Postgres via the host)
docker compose -f infra/docker/compose.yaml up -d
# 2. Start the GUI (the --profile flag is required; it is opt-in)
docker compose -f infra/docker/compose.yaml --profile tools up -d pgweb
# 3. Open http://localhost:8085 and pick a bookmark
# local -> writable | prod -> read-only
# 4. Prod bookmark (one-time, optional)
cp infra/docker/pgweb/bookmarks/prod.toml.example infra/docker/pgweb/bookmarks/prod.toml
# then fill in host + password; the host is in apps/api/.env.prod.backup
# no restart needed — pgweb rescans the bookmarks dir on each request
# 5. Stop it
docker compose -f infra/docker/compose.yaml --profile tools stop pgweb
prod.toml is gitignored — only local.toml (throwaway creds) and the .example are committed.replybox_ro, not the application's replybox user.infra/sql/pgweb-readonly-role.sql.readonly = true is a second layer, not a replacement — pgweb enforces it127.0.0.1 only, and pgweb runs with --bookmarks-only so noSee the master plan (mirrored from the planning session) for phased delivery.
Currently delivered: Phase 1 — Skeleton.