# 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: 1. Client POSTs "send message" to the backend REST API (the client's realtime socket is effectively **receive-only**). 2. Backend authorizes, writes the message to Postgres (source of truth), then calls Centrifugo's HTTP API to publish into the channel. 3. 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:` — one channel per room; all members subscribe. - `user:` — 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. 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:, user: │ │ │ ├── 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-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: 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). - `messages` needs 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:`. 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 `core` package 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.