192 lines
9.8 KiB
Markdown
192 lines
9.8 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" 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:<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. 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-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:<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 `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.
|