Messenger/specs.md
Заид Омар Медхат | Zaid Omar Medhat 62f20f8843 init
2026-07-10 13:31:12 +05:00

417 lines
24 KiB
Markdown

# Project Spec — Realtime Chat & Calls App
> This document is a brief for Claude Code (plan mode). Read it fully, then propose a
> plan before writing any code. Build the skeleton first, then implement part by part
> with check-ins. Do NOT scaffold the whole thing in one pass. See "How to work" (section 11)
> — it is as important as the architecture.
---
## 1. What we are building
A self-hosted, cross-platform messaging app with:
- **Production-grade auth**: register, login, token refresh, logout, with username,
display name, avatar, and optional phone number — a Telegram-style profile model
(see section 4).
- **1:1 direct messages and group rooms** (Discord/Slack-shaped).
- **Media messages**: voice notes, video messages, images (files, not streamed).
- **Live features**: typing indicators, online/presence, last-seen, read receipts,
edits, reactions — all delivered in realtime.
- **Voice/video calls with screen share** — group conferences and 1:1. **This is a LATER
phase (Phase 8). Do not scaffold call/WebRTC code until the chat core is working and I
say so.**
Targets: web, iOS, Android, and desktop (macOS + Windows).
Everything server-side runs in **Docker Compose** — the backend included, not just the
supporting services. See section 9.
## 2. Core mental model (read this before anything else)
The realtime transport (**Centrifugo**) is a **dumb, fast pipe**, NOT the chat server.
The **backend is the brain**.
**Sending is over REST; receiving is over the WebSocket.** This split is deliberate and is
the most common thing people get wrong — do not "optimize" it away:
1. **Send** (client → server): a normal REST POST to the backend. The client's realtime
socket is effectively **receive-only**. Sending must go through the backend because
that is the only place validation, membership checks, persistence, server-assigned IDs,
ordering, and rate-limiting can happen.
2. The backend authorizes, writes to Postgres (source of truth), then calls Centrifugo's
HTTP API to publish into the channel.
3. **Receive** (server → client): Centrifugo fans the event out over the WebSocket to all
subscribers — including the original sender, so all their devices stay in sync and they
get the same server-assigned ID and ordering as everyone else. The sender renders the
message from the socket echo, NOT from the REST response.
Do NOT let clients publish chat messages directly through Centrifugo, and do NOT tunnel
sends through Centrifugo RPC — a plain REST POST reaches the same backend endpoint more
simply, and send latency is dominated by the DB write anyway.
### Channel model
- `room:<roomId>` — one channel per room; all members subscribe.
- `user:<userId>` — one **personal channel** per user. DMs are delivered by publishing the
message into BOTH participants' personal channels; the client routes by `conversationId`
in the payload. Scales to one subscription per user regardless of conversation count, and
doubles as the notification / unread / "added to room" / read-receipt channel.
### Two auth gates (keep them separate) — see section 4 for the full auth design
- **Connection auth**: backend mints a Centrifugo JWT (shared secret with Centrifugo) after
the user is authenticated; Centrifugo verifies the signature — no backend call per
connection. This is a SEPARATE token from the app's own access token.
- **Subscription auth**: use the **subscribe-proxy** pattern — Centrifugo calls a backend
endpoint on each subscribe to check Postgres membership ("can user U join channel C?").
Reflects current DB state (kicked users lose access immediately). Personal-channel
subscriptions are authorized by matching the token's user id.
### Boundaries to respect
- Centrifugo history is a **short recovery buffer** (reconnect replay), NOT the message
store. Load conversation history from Postgres via REST.
- **Server-assigned monotonic message IDs + ordering** and **client-side dedupe** are
required from day one (messages arrive both live and via history load, and can overlap).
- On reconnect: subscribe FIRST, buffer incoming live events, THEN load history via REST,
then merge + dedupe. Ordering of these two steps prevents "missing message" gaps.
- Media (voice notes / video messages / images) upload to object storage (MinIO) via the
backend (presigned URLs); only a **reference** (URL + metadata) is published through the
channel. Never push binary through Centrifugo.
## 3. Event taxonomy (how every realtime feature is handled)
Every live feature is just another event on the same receive socket. There is no separate
system for typing vs presence vs messages — you publish different event types onto the same
channels and the one socket carries them all. Each event falls into exactly one bucket:
### Bucket A — Durable + must be correct → through the backend (persist, then publish)
The "brain" rule applies: REST in, backend validates + writes to Postgres, backend
publishes to the relevant channel, clients receive over the socket.
- `message.new` — a new chat message.
- `message.edit` — edited message (store edit + edited_at).
- `message.delete` — soft delete.
- `reaction.add` / `reaction.remove` — emoji reactions.
- `read.receipt` — "user X has read up to message N" (updates `read_state`, published to
conversation participants so checkmarks update live).
- `last_seen` — timestamp written to Postgres on disconnect or periodically; loaded via
REST with conversation metadata. Durable because it must survive restarts and be queryable.
- `room.membership` — added to / removed from a room (published to the affected user's
personal channel).
- `profile.update` — display name / avatar / username changes (published to that user's
personal channel so contacts see the update live).
### Bucket B — Ephemeral + throwaway → published to the channel, NOT persisted
No database write. Backend involvement optional (a tiny fire-and-forget REST call, or a
constrained client-side publish, is acceptable here precisely because there is nothing to
validate or store — worst case a spurious flicker).
- `typing.start` / `typing.stop` — published to `room:<id>` or the DM's channels;
auto-clears on the client after ~3s if no follow-up. High-frequency, never stored.
- `recording.audio` / `recording.video` — "recording a voice note…" style indicators.
### Bucket C — Connection-derived → Centrifugo built-in presence, no code of your own
- `presence` / online status — use Centrifugo's built-in channel presence (join/leave
events, "who is subscribed"). Room online lists come from presence on `room:<id>`; a
user's global online dot is derived from whether they hold an active `user:<id>`
connection. Subscribe to presence updates on the socket.
**Rule of thumb for any future feature:** if it must be correct and durable → Bucket A. If
it's throwaway → Bucket B. If it's "who's connected" → Bucket C. Define the event type,
publish it, handle it on the client — never stand up new infrastructure.
All event type names and payload shapes live in the shared `core` package (section 6).
## 4. Auth & user identity (production-grade — Telegram-style profile)
Auth is security-critical and hard to change later. Use vetted, well-established
building blocks — DO NOT hand-roll crypto, hashing, or token logic. Where a mature library
exists (password hashing, JWT handling), use it.
### Identity / profile model
- **username** — unique, immutable-ish public handle (like Telegram @handle). Case-insensitive
unique. Used for search / mentions / DM initiation. Required.
- **display_name** — freely editable, non-unique, shown in UI. Required.
- **avatar** — optional image, uploaded to MinIO, stored as a reference (URL + metadata),
same upload path as media messages. Changing it emits `profile.update` (Bucket A).
- **phone number** — OPTIONAL (like Telegram, not required to have an account). If present,
stored normalized (E.164). May be used later for contact discovery; keep it optional and
privacy-controlled from day one.
- **email** — decide in planning whether email is the primary credential or optional. Default
assumption: email OR username + password for login; phone optional and additive. Confirm
with me before locking this.
### Credentials & registration
- **Register**: username + display_name + password (+ optional email/phone). Enforce
username uniqueness and a sane password policy (length-based, not silly complexity rules).
- **Password hashing**: use **argon2id** (preferred) or bcrypt via a maintained library.
Never store plaintext, never invent a hashing scheme, never use fast hashes (MD5/SHA).
- **Login**: credential + password → issue an access token + refresh token (see below).
- Rate-limit login and register endpoints to blunt credential stuffing / enumeration.
Return non-enumerating errors ("invalid credentials", not "no such user").
### Token model (this is the production-grade part — get it right)
Two app tokens, plus the separate Centrifugo token:
- **Access token** — short-lived JWT (e.g. ~15 min), sent on every REST request
(Authorization: Bearer). Stateless; carries user id + minimal claims. Never long-lived.
- **Refresh token** — long-lived, **opaque** (random, not a JWT), stored server-side
(hashed) in a `refresh_tokens` table, one row per device/session. Used only at the
`/auth/refresh` endpoint to mint a new access token.
- **Refresh rotation with reuse detection**: every refresh issues a NEW refresh token and
invalidates the old one. If an already-used (rotated-out) refresh token is presented
again, treat it as theft — revoke the whole token family / session and force re-login.
This is a required behavior, not optional.
- **Centrifugo connection token** — a SEPARATE short-lived JWT minted by the backend
(signed with the Centrifugo shared secret) once the user is authenticated. Do not reuse
the app access token for Centrifugo. The client fetches/refreshes it via an authenticated
backend endpoint.
### Sessions & devices
- `refresh_tokens` (or `sessions`) table tracks each active session: user_id, hashed token,
device/user-agent info, created_at, last_used_at, expires_at, revoked_at.
- **Logout** revokes the current session's refresh token. Support "log out this device" and
"log out all devices" (revoke all rows for the user).
- Multi-device is expected (Telegram-style): a user may have several active sessions at once;
the personal-channel model already supports fan-out to all their devices.
### Endpoints (starting set — refine in planning)
`POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout`,
`POST /auth/logout-all`, `GET /me`, `PATCH /me` (display_name / avatar / phone),
`GET /auth/sessions`, `POST /auth/centrifugo-token`, `GET /users/:username` (public profile),
avatar upload endpoint (presigned, via the media path).
### Security requirements (non-negotiable)
- Passwords hashed with argon2id/bcrypt via a library; never logged, never returned.
- Refresh tokens opaque + hashed at rest + rotated + reuse-detected.
- Access tokens short-lived; secrets from env (section 9), never committed.
- Rate-limit auth endpoints; non-enumerating error messages.
- Validate all inputs against JSON Schema from `core`.
- Treat the Centrifugo token as separate and short-lived.
- DO NOT hand-roll cryptography or token schemes — use maintained libraries and standard
patterns. Flag to me if a requirement seems to need custom crypto (it almost never does).
## 5. Stack (decided — do not re-litigate)
- **Language everywhere:** TypeScript.
- **Monorepo:** single repo, pnpm workspaces. (NOT separate repos — shared code is imported
by source so type changes surface as immediate compile errors.)
- **Backend framework:** Fastify. (NestJS considered and rejected: too much ceremony/DI for
a small team and a realtime/event-shaped workload.)
- **Auth libraries:** argon2 (or bcrypt) for hashing; a maintained JWT library for access
tokens; opaque random refresh tokens stored hashed. No custom crypto.
- **Validation:** JSON Schema via Fastify's built-in support, sourced from `core`.
- **Realtime transport:** Centrifugo (self-hosted, runs in Docker Compose).
- **DB:** Postgres 16 (source of truth). **Redis** for Centrifugo scaling + history/recovery
(and available for auth rate-limiting / token-family tracking if useful).
- **Object storage:** MinIO (S3-compatible) — media messages AND avatars.
- **Web frontend:** React + TypeScript + Vite.
- **Mobile frontend:** React Native + Expo + TypeScript.
- **Desktop:** thin Tauri (preferred) or Electron shell wrapping the web build.
- **Calls (LATER, Phase 8):** LiveKit (SFU) + coturn (TURN over TLS/443). Not now.
## 6. Monorepo layout
```
/
├── package.json # pnpm workspace root
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── docker-compose.yml # base: all backend-side services (see section 9)
├── docker-compose.override.yml # local dev conveniences (hot reload, exposed ports)
├── docker-compose.prod.yml # deploy overrides (restart policies, no source mounts)
├── .env.example # every env var documented; real .env is gitignored
├── packages/
│ ├── core/ # THE KEYSTONE — platform-agnostic, imported by all
│ │ ├── src/
│ │ │ ├── types/ # message/event/API payload types + user/profile types
│ │ │ ├── events/ # event type constants + payloads (section 3 taxonomy)
│ │ │ ├── schemas/ # JSON Schemas (backend validates, clients type off these)
│ │ │ ├── channels/ # channel-name builders: room:<id>, user:<id>
│ │ │ ├── realtime/ # Centrifugo SDK wrapper (UI-agnostic)
│ │ │ ├── api/ # typed API client functions (incl. auth flows)
│ │ │ └── messages/ # dedupe + ordering logic
│ │ └── package.json
│ ├── backend/ # Fastify service; imports core; runs in Docker
│ │ ├── src/
│ │ │ ├── routes/ # auth, users/profile, messages, rooms, conversations, media, subscribe-proxy
│ │ │ ├── plugins/ # db, redis, centrifugo client, auth/jwt, rate-limit
│ │ │ ├── auth/ # hashing, token issue/rotate/verify, session management
│ │ │ ├── db/ # migrations + queries
│ │ │ └── server.ts
│ │ ├── Dockerfile # multi-stage; see section 9
│ │ └── package.json
│ ├── web/ # React + Vite; imports core
│ │ ├── Dockerfile # optional: containerized static build for deploy
│ │ └── package.json
│ ├── mobile/ # React Native + Expo; imports core (not containerized)
│ │ └── package.json
│ └── desktop/ # Tauri/Electron shell over web build (not containerized)
│ └── package.json
├── infra/
│ ├── centrifugo/config.json # shared JWT secret, subscribe proxy, namespaces
│ ├── postgres/init/ # init SQL if needed
│ └── nginx/ # reverse proxy config (routes /api and /connection/websocket)
└── PROJECT_SPEC.md
```
### Rules for `core`
- No `react-dom` or `react-native` imports anywhere in `core`. It is pure TS.
- Keep React hooks / state logic UI-agnostic where possible so it can migrate into `core`
later. Do NOT bake DOM/RN assumptions into the Centrifugo/API wrappers.
- One source of truth for payload shapes, event definitions, AND auth/profile types: all
live here; backend and clients both consume them.
## 7. Data model (starting point — refine in planning)
Tables (Postgres): `users`, `refresh_tokens` (aka `sessions`), `rooms`, `room_members`,
`conversations` (for DMs), `messages`, `reactions`, `read_state`.
- `users`: id, username (unique, case-insensitive), display_name, avatar_ref (nullable),
phone (nullable, E.164), email (nullable or primary — confirm in planning),
password_hash, created_at, last_seen_at.
- `refresh_tokens`: id, user_id, token_hash, device/user-agent, created_at, last_used_at,
expires_at, revoked_at, family/rotation id for reuse detection.
- `messages`: **server-assigned monotonic id/sequence** (BIGSERIAL or per-room counter) +
a stable id for client-side dedupe.
- Membership drives channel access (the subscribe-proxy checks these tables).
- Typing/presence/recording are NOT tables (Buckets B and C — not persisted).
## 8. Build phases (implement in this order)
**Phase 0 — Skeleton + Docker.** Monorepo tooling, workspaces, tsconfig, empty packages that
build and import `core`. Full `docker-compose.yml` bringing up Centrifugo + Postgres + Redis
- MinIO + backend, with the dev override for hot reload. No features yet. Verify the whole
stack boots with one `docker compose up` and the backend reaches every service.
**Phase 1 — Auth (production-grade).** Implement section 4 in full: register, login, refresh
with rotation + reuse detection, logout / logout-all, sessions table, argon2 hashing, access
- refresh tokens, `/me` + `PATCH /me`, rate-limiting on auth endpoints. This is the
foundation everything else authenticates against — get it solid before features.
**Phase 2 — Realtime connection.** Backend mints the separate Centrifugo token; web client
connects its (receive-only) socket using it and subscribes to its personal channel via the
subscribe-proxy. Prove authenticated end-to-end connectivity.
**Phase 3 — Rooms + messaging.** Room CRUD, membership, subscribe-proxy authorization,
send-message REST endpoint → persist → publish to `room:<id>`. History load from Postgres.
Server-assigned ids + client dedupe. `message.new` end to end.
**Phase 4 — DMs.** Personal-channel delivery (publish to both participants), conversation
model, client routing by `conversationId`.
**Phase 5 — Live features (event taxonomy).** Typing (Bucket B), presence (Bucket C,
built-in), last-seen (Bucket A), read receipts (Bucket A), edits + reactions (Bucket A),
profile.update (Bucket A). Implement per the section-3 buckets.
**Phase 6 — Media + avatars.** MinIO presigned uploads; voice notes / video messages /
images as references published through channels; user avatars via the same path.
**Phase 7 — Mobile + desktop clients.** React Native + Expo consuming the same `core` (reuse
realtime + API + auth layer; platform-specific UI only). Then the Tauri/Electron desktop
shell over the web build.
**Phase 8 (LATER, separate effort) — Calls.** LiveKit + coturn (added as new Docker Compose
services), call signaling over Centrifugo (ring via personal channel), token minting in
backend, screen share. Do NOT start until explicitly told. TURN-over-443 + simulcast are
legitimate reliability engineering, NOT censorship circumvention (see section 12).
## 9. Docker & deployment (IMPORTANT — Docker Compose is the primary runtime)
Everything server-side runs in Docker Compose, for BOTH local development and deploy. The
backend is containerized too — do not assume it runs on the host. Aim for a single
`docker compose up` to bring up the entire backend stack locally.
### Compose file strategy (use overrides, don't fork the file)
- `docker-compose.yml` — base definition of all services: `backend`, `centrifugo`,
`postgres`, `redis`, `minio`, `nginx`. Sensible defaults, named volumes, a private
network, healthchecks, and `depends_on` with `condition: service_healthy` so the backend
waits for Postgres/Redis to be ready.
- `docker-compose.override.yml` — auto-loaded in local dev: mount backend source for hot
reload (e.g. `tsx watch` / `nodemon`), expose service ports to the host, use dev secrets,
seed data. Convenience only.
- `docker-compose.prod.yml` — explicit for deploy: `restart: unless-stopped`, NO source
mounts (use the built image), secrets from real env, tighter exposure (only nginx faces
the internet). Deploy with
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`.
### Backend Dockerfile
- **Multi-stage**: a build stage (install pnpm, build `core` + `backend`) and a slim runtime
stage (node:LTS-alpine or distroless, production deps only). The monorepo means the build
stage must handle pnpm workspaces — build `core` first, then `backend`.
- Run as a **non-root** user. Add a `HEALTHCHECK` hitting a `/health` endpoint.
- `.dockerignore` to keep `node_modules`, build artifacts, and other packages out of context.
### Services — requirements
- **backend**: depends_on postgres+redis (healthy), reads all config from env, exposes only
to the internal network in prod (nginx proxies it). `/health` and `/ready` endpoints.
Runs DB migrations on startup or via a one-shot migration service.
- **centrifugo**: official image, config from `infra/centrifugo/config.json` + env. Shared
JWT secret via env, subscribe-proxy endpoint pointing at the backend service name, `room:`
and `user:` namespaces defined, Redis engine + history enabled for recovery. **Raise the
`nofile` ulimit** (e.g. 65536) — FD exhaustion is the first wall for a realtime server.
- **postgres**: version 16, named volume, healthcheck (`pg_isready`), init SQL mount if
useful. Never expose to the internet in prod.
- **redis**: Centrifugo scaling + history/recovery; present from Phase 0 as the coordination
layer future clustering depends on. Named volume if persistence wanted.
- **minio**: S3-compatible object storage for media + avatars; console + API, named volume,
bucket bootstrap on startup.
- **nginx**: reverse proxy / TLS termination. Routes `/api/*` → backend and
`/connection/websocket` → centrifugo, passing WebSocket upgrade headers correctly. In prod
this is the only internet-facing service.
### General Docker rules
- Secrets via env / `.env` (gitignored) — never baked into images or committed. Ship a fully
documented `.env.example` (include all auth secrets: JWT signing key, Centrifugo shared
secret, DB/Redis/MinIO creds).
- Named volumes for all stateful services (postgres, redis, minio) so data survives
`docker compose down`.
- Private network for inter-service traffic; only nginx published to the host in prod.
- Pin image versions (no bare `latest`) for reproducibility.
- Every long-running service has a healthcheck; the backend uses them via `depends_on`.
- Local dev must be one command. Document it in the README the scaffold generates.
## 10. Scaling assumptions (design for, don't build yet)
- Backend and Centrifugo are **stateless** — no in-memory session state (sessions live in
Postgres/Redis, not process memory). This is what lets them scale horizontally later
(multiple backend and Centrifugo replicas behind nginx, coordinated by Redis).
- Media plane (LiveKit/coturn, Phase 8) stays **physically separate** from the control plane.
- Redis present from Phase 0 (coordination layer for later Centrifugo + LiveKit clustering).
## 11. How to work (IMPORTANT — follow this)
- **Plan first.** Before writing code, produce a plan for the current phase only and wait for
my approval. Do not jump ahead to later phases.
- **Explain before implementing.** For each phase, briefly explain the approach and the
reasoning behind key decisions before generating code. I prefer understanding the "why".
- **Go part by part with check-ins.** Implement one coherent piece, stop, let me review, then
continue. Do not dump large amounts of generated code in one go.
- **Do not add features I didn't ask for.** No calls/WebRTC before Phase 8. No speculative
abstractions.
- **Ask when a decision is ambiguous** rather than guessing, especially the data model, API
shapes, event payloads, and the email-vs-username-vs-phone credential question in section 4.
- **Respect the decided stack** in section 5 — don't substitute frameworks or add heavy
dependencies without flagging why.
- **For auth, use vetted libraries and standard patterns — never hand-roll crypto or token
logic.** Flag anything that seems to need custom crypto.
- Keep the shared `core` package clean of platform-specific imports.
- Prefer the Docker Compose workflow (section 9) for anything that touches running services.