9.8 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" at the bottom — it is as important as the architecture.
1. What we are building
A self-hosted, cross-platform messaging app with:
- 1:1 direct messages and group messages (Telegram-shaped).
- Media messages: voice notes, video messages, images, videos (files).
- Voice/video calls with screen share — group conferences and 1:1. This is a LATER phase. Do not scaffold call/WebRTC code until the chat core is working and I say so.
Targets: web, iOS, Android, and desktop (macOS + Windows).
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. The message-send flow is deliberately:
- Client POSTs "send message" to the backend REST API (the client's realtime socket is effectively receive-only).
- Backend authorizes, writes the message to Postgres (source of truth), then calls Centrifugo's HTTP API to publish into the channel.
- Centrifugo fans it out to subscribers.
Do not let clients publish directly through Centrifugo. All business logic, validation, and persistence live in the backend.
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. This scales to one subscription per user regardless of conversation count, and doubles as the notification / unread / "added to room" channel.
Two auth gates (keep them separate)
- Connection auth: backend mints a JWT (shared secret with Centrifugo) at login; Centrifugo verifies the signature — no backend call per connection.
- 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?"). This 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 message IDs + ordering and client-side dedupe are required from day one (messages can arrive both live and via history load). These are painful to retrofit.
- 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. 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 was considered and rejected: too much ceremony/DI for a small team and a realtime/event-shaped workload.)
- Validation: JSON Schema via Fastify's built-in support, sourced from the shared package.
- Realtime transport: Centrifugo (self-hosted, external service).
- DB: Postgres 16 (source of truth). Redis for Centrifugo scaling + history/recovery.
- Object storage: MinIO (S3-compatible).
- 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): LiveKit (SFU) + coturn (TURN over TLS/443). Not now.
4. Monorepo layout
/
├── package.json # pnpm workspace root
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── packages/
│ ├── core/ # THE KEYSTONE — platform-agnostic, imported by all
│ │ ├── src/
│ │ │ ├── types/ # message/event/API payload types
│ │ │ ├── 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
│ │ │ └── messages/ # dedupe + ordering logic
│ │ └── package.json
│ ├── backend/ # Fastify service; imports core
│ │ ├── src/
│ │ │ ├── routes/ # auth, messages, rooms, conversations, media, subscribe-proxy
│ │ │ ├── plugins/ # db, redis, centrifugo client, auth/jwt
│ │ │ ├── db/ # migrations + queries
│ │ │ └── server.ts
│ │ └── package.json
│ ├── web/ # React + Vite; imports core
│ │ └── package.json
│ ├── mobile/ # React Native + Expo; imports core
│ │ └── package.json
│ └── desktop/ # Tauri/Electron shell over web build (config-heavy)
│ └── package.json
├── infra/ # docker-compose for Centrifugo, Postgres, Redis, MinIO
│ ├── docker-compose.yml
│ └── centrifugo/config.json
└── 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: types + JSON Schemas live here, backend and clients both consume them.
5. Data model (starting point — refine in planning)
Tables (Postgres): users, rooms, room_members, conversations (for DMs),
messages, read_state (per-user, per-conversation last-read message id).
messagesneeds a server-assigned monotonic id/sequence (e.g. BIGSERIAL or per-room counter) and a stable id used for client-side dedupe.- Membership drives channel access (the subscribe-proxy checks these tables).
6. Build phases (implement in this order)
Phase 0 — Skeleton. Monorepo tooling, workspaces, tsconfig, empty packages that build
and import core. infra/docker-compose.yml bringing up Centrifugo + Postgres + Redis +
MinIO locally. No features yet. Verify everything boots.
Phase 1 — Auth + connection. User signup/login in backend; JWT minting for Centrifugo; web client connects its (receive-only) socket and subscribes to its personal channel via the subscribe-proxy. Prove end-to-end connectivity.
Phase 2 — 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. Web client sends/receives in a room.
Phase 3 — DMs. Personal-channel delivery (publish to both participants), conversation
model, client routing by conversationId.
Phase 4 — Media messages. MinIO presigned uploads; voice notes / video messages / images as references published through channels.
Phase 5 — Mobile client. React Native + Expo consuming the same core. Reuse realtime
- API layer; platform-specific UI only.
Phase 6 — Desktop shell. Tauri/Electron over the web build.
Phase 7 (LATER, separate effort) — Calls. LiveKit + coturn, call signaling over Centrifugo (ring via personal channel), token minting in backend, screen share. Do NOT start until explicitly told; video across platforms is the hardest part of the project and gets its own focused phase.
7. Scaling assumptions (design for, don't build yet)
- Backend and Centrifugo are stateless — no in-memory session state; everything in Postgres/Redis. This is what lets them scale horizontally later.
- Media plane (LiveKit/coturn) stays physically separate from the control plane.
- Redis is present from Phase 0 (coordination layer for later Centrifugo + LiveKit clustering).
8. 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 7. No speculative abstractions.
- Ask when a decision is ambiguous rather than guessing, especially on the data model and API shapes.
- Respect the decided stack in section 3 — don't substitute frameworks or add heavy dependencies without flagging why.
- Keep the shared
corepackage clean of platform-specific imports.
9. Legal / deployment note (context, not a task)
Deployment target includes the Russian Federation. There are messenger-registration and data-localization rules that carry real liability; this is for the humans to handle with local legal advice — not something to implement or work around in code. Network reliability on poor connections (TURN-over-443, simulcast) is legitimate engineering and belongs in the LATER calls phase, but is not a censorship-circumvention feature and should not be built as one.