24 KiB
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:
- 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.
- The backend authorizes, writes to Postgres (source of truth), then calls Centrifugo's HTTP API to publish into the channel.
- 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 byconversationIdin 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" (updatesread_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 toroom:<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 onroom:<id>; a user's global online dot is derived from whether they hold an activeuser:<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_tokenstable, one row per device/session. Used only at the/auth/refreshendpoint 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(orsessions) 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-domorreact-nativeimports anywhere incore. It is pure TS. - Keep React hooks / state logic UI-agnostic where possible so it can migrate into
corelater. 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 upand 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, anddepends_onwithcondition: service_healthyso 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 withdocker 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 — buildcorefirst, thenbackend. - Run as a non-root user. Add a
HEALTHCHECKhitting a/healthendpoint. .dockerignoreto keepnode_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).
/healthand/readyendpoints. 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:anduser:namespaces defined, Redis engine + history enabled for recovery. Raise thenofileulimit (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
corepackage clean of platform-specific imports. - Prefer the Docker Compose workflow (section 9) for anything that touches running services.