init
This commit is contained in:
commit
62f20f8843
66 changed files with 6200 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
.git
|
||||||
|
**/.expo
|
||||||
|
packages/desktop/src-tauri/target
|
||||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
58
.env.example
Normal file
58
.env.example
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# ============================================================================
|
||||||
|
# Altricade Messenger — environment template.
|
||||||
|
# Copy to `.env` (gitignored) and replace every value below.
|
||||||
|
# The values here are CLEARLY-FAKE dev placeholders — NEVER use in production.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# --- General ---
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# --- Backend (Fastify) ---
|
||||||
|
BACKEND_PORT=4000
|
||||||
|
# Public base URL nginx exposes the API under (used for CORS, links).
|
||||||
|
PUBLIC_API_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# --- App auth tokens (Phase 1) ---
|
||||||
|
# Signing key for short-lived access JWTs. Generate: `openssl rand -base64 48`
|
||||||
|
JWT_ACCESS_SECRET=dev-CHANGE-ME-access-secret-not-for-prod
|
||||||
|
ACCESS_TOKEN_TTL=15m
|
||||||
|
REFRESH_TOKEN_TTL=30d
|
||||||
|
|
||||||
|
# --- Centrifugo connection token (SEPARATE from app tokens) ---
|
||||||
|
# HMAC secret SHARED between backend (mints) and Centrifugo (verifies).
|
||||||
|
# Generate: `openssl rand -base64 48`
|
||||||
|
CENTRIFUGO_TOKEN_HMAC_SECRET=dev-CHANGE-ME-centrifugo-hmac-not-for-prod
|
||||||
|
# API key the backend uses to call Centrifugo's server HTTP API (publish, etc).
|
||||||
|
CENTRIFUGO_API_KEY=dev-CHANGE-ME-centrifugo-api-key
|
||||||
|
# Internal URL of the Centrifugo HTTP API (service name on the compose network).
|
||||||
|
CENTRIFUGO_API_URL=http://centrifugo:8000/api
|
||||||
|
|
||||||
|
# --- Postgres ---
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=altricade
|
||||||
|
POSTGRES_USER=altricade
|
||||||
|
POSTGRES_PASSWORD=dev-CHANGE-ME-postgres-password
|
||||||
|
# Full URL derived from the above (used by backend + migrations).
|
||||||
|
DATABASE_URL=postgres://altricade:dev-CHANGE-ME-postgres-password@postgres:5432/altricade
|
||||||
|
|
||||||
|
# --- Redis (Centrifugo scaling + history/recovery; auth rate-limiting later) ---
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# --- MinIO (object storage: media messages + avatars) ---
|
||||||
|
MINIO_ROOT_USER=altricade
|
||||||
|
MINIO_ROOT_PASSWORD=dev-CHANGE-ME-minio-password
|
||||||
|
MINIO_ENDPOINT=minio
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_BUCKET_MEDIA=media
|
||||||
|
MINIO_BUCKET_AVATARS=avatars
|
||||||
|
|
||||||
|
# --- nginx (public entrypoint) ---
|
||||||
|
NGINX_HTTP_PORT=8080
|
||||||
|
|
||||||
|
# --- Web (Vite) ---
|
||||||
|
# Base URL the web client points at for REST + realtime.
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# env / secrets — never commit real secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# editor / os
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# expo / mobile
|
||||||
|
.expo/
|
||||||
|
*.orig.*
|
||||||
|
|
||||||
|
# tauri / desktop
|
||||||
|
packages/desktop/src-tauri/target/
|
||||||
|
|
||||||
|
# docker volumes (if ever bind-mounted locally)
|
||||||
|
.data/
|
||||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
85
README.md
Normal file
85
README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Altricade Messenger
|
||||||
|
|
||||||
|
A self-hosted, cross-platform realtime messenger (chat + later calls). TypeScript
|
||||||
|
everywhere, pnpm monorepo. See `specs.md` for the full architecture brief.
|
||||||
|
|
||||||
|
> **Status:** Phase 0 — skeleton + Docker. The full backend stack boots with one
|
||||||
|
> command; no product features yet. Phases are built one at a time (see `specs.md` §8).
|
||||||
|
|
||||||
|
## Architecture in one paragraph
|
||||||
|
|
||||||
|
Centrifugo is a **dumb, fast pipe**; the **backend is the brain**. Clients **send**
|
||||||
|
over REST and **receive** over the Centrifugo WebSocket. The backend authorizes,
|
||||||
|
persists to Postgres (source of truth), then publishes to Centrifugo which fans out
|
||||||
|
to subscribers. Media lives in MinIO; only references travel through channels. Redis
|
||||||
|
backs Centrifugo history/recovery and clustering.
|
||||||
|
|
||||||
|
## Monorepo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
core/ # THE KEYSTONE — pure TS, imported by all (types, events, channels, ...)
|
||||||
|
backend/ # Fastify service (Docker) — imports core
|
||||||
|
web/ # React + Vite — imports core (Feature-Sliced Design)
|
||||||
|
mobile/ # React Native + Expo (Phase 7) — placeholder
|
||||||
|
desktop/ # Tauri shell over web build (Phase 7) — placeholder
|
||||||
|
infra/ # centrifugo, nginx, postgres init
|
||||||
|
docker-compose*.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`core` is consumed **as source** across packages, so a type change surfaces as an
|
||||||
|
immediate compile error everywhere. Builds go through bundlers (Vite for web, tsup
|
||||||
|
for backend); `tsc` is used only for type-checking.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js ≥ 22 and pnpm (via `corepack enable`)
|
||||||
|
- Docker + Docker Compose
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install the workspace
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 2. Configure env (copy the template, adjust if you like — dev defaults work)
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. Bring up the whole backend stack (Postgres, Redis, MinIO, Centrifugo,
|
||||||
|
# backend, nginx) with hot reload. The dev override is auto-loaded.
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# 4. Run the web client (separate terminal)
|
||||||
|
pnpm --filter @altricade/web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints (via the dev override):
|
||||||
|
|
||||||
|
- Gateway (nginx): http://localhost:8080
|
||||||
|
- API health: http://localhost:8080/api/health → `{ "status": "ok" }`
|
||||||
|
- API readiness: http://localhost:8080/api/ready → 200 only when Postgres + Redis + MinIO are reachable
|
||||||
|
- Web client: http://localhost:5173
|
||||||
|
- MinIO console: http://localhost:9001
|
||||||
|
|
||||||
|
## Quality gates (enforced mechanically — a violation fails the build)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # strict TypeScript across all packages
|
||||||
|
pnpm lint # ESLint: no any, no type assertions, no non-null !, FSD import boundaries
|
||||||
|
pnpm build # bundle backend + build web
|
||||||
|
pnpm format # prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
Standards: strict TS, no `any`, no `as` type assertions, no `!`, functional
|
||||||
|
iteration, and strict **Feature-Sliced Design** import boundaries (frontend) plus
|
||||||
|
layered backend modules. Dependencies are always added via `pnpm add` (never
|
||||||
|
hand-pinned) at their latest trusted versions.
|
||||||
|
|
||||||
|
## Deploy (production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Only nginx is published to the host; all other services stay on the private network.
|
||||||
|
Secrets come from the environment — never commit a real `.env`.
|
||||||
192
claude.md
Normal file
192
claude.md
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# 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.
|
||||||
35
docker-compose.override.yml
Normal file
35
docker-compose.override.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Local-dev conveniences — auto-loaded by `docker compose up`. Publishes service
|
||||||
|
# ports to the host and runs the backend with hot reload (tsx watch). Not for prod.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
ports:
|
||||||
|
- '${POSTGRES_PORT:-5432}:5432'
|
||||||
|
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- '${REDIS_PORT:-6379}:6379'
|
||||||
|
|
||||||
|
minio:
|
||||||
|
ports:
|
||||||
|
- '${MINIO_PORT:-9000}:9000'
|
||||||
|
- '9001:9001'
|
||||||
|
|
||||||
|
centrifugo:
|
||||||
|
ports:
|
||||||
|
- '8000:8000'
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/backend/Dockerfile
|
||||||
|
target: dev
|
||||||
|
command: ['pnpm', 'dev']
|
||||||
|
volumes:
|
||||||
|
- ./packages/backend/src:/repo/packages/backend/src
|
||||||
|
- ./packages/core/src:/repo/packages/core/src
|
||||||
|
ports:
|
||||||
|
- '${BACKEND_PORT:-4000}:4000'
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
ports:
|
||||||
|
- '${NGINX_HTTP_PORT:-8080}:80'
|
||||||
24
docker-compose.prod.yml
Normal file
24
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Deploy overrides — use explicitly:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
# No source mounts, restart policies, and only nginx faces the host.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
centrifugo:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
130
docker-compose.yml
Normal file
130
docker-compose.yml
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Base stack — all server-side services. Self-contained: `docker compose up`
|
||||||
|
# builds and boots everything. No host ports are published here (only the dev
|
||||||
|
# override and prod files expose ports); services talk over the private network.
|
||||||
|
name: altricade
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./infra/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: ['server', '/data', '--console-address', ':9001']
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'curl -sf http://localhost:9000/minio/health/live || exit 1']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
# One-shot: create the media + avatars buckets, then exit.
|
||||||
|
minio-setup:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file: .env
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
mc alias set local http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||||
|
mc mb --ignore-existing "local/$${MINIO_BUCKET_MEDIA}" &&
|
||||||
|
mc mb --ignore-existing "local/$${MINIO_BUCKET_AVATARS}" &&
|
||||||
|
echo "minio buckets ready"
|
||||||
|
restart: 'no'
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
centrifugo:
|
||||||
|
image: centrifugo/centrifugo:v6
|
||||||
|
command: ['centrifugo', '-c', '/centrifugo/config.json']
|
||||||
|
environment:
|
||||||
|
CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY: ${CENTRIFUGO_TOKEN_HMAC_SECRET}
|
||||||
|
CENTRIFUGO_HTTP_API_KEY: ${CENTRIFUGO_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./infra/centrifugo/config.json:/centrifugo/config.json:ro
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 65536
|
||||||
|
hard: 65536
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
# One-shot: run DB migrations, then exit. Backend waits for this to complete.
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/backend/Dockerfile
|
||||||
|
command: ['node_modules/.bin/node-pg-migrate', 'up']
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: 'no'
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/backend/Dockerfile
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
volumes:
|
||||||
|
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- centrifugo
|
||||||
|
networks: [altricade]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
|
minio-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
altricade:
|
||||||
|
driver: bridge
|
||||||
86
eslint.config.mjs
Normal file
86
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Shared flat ESLint config — the mechanical enforcement of the project's
|
||||||
|
// non-negotiable code-quality standards. Each package's eslint.config.mjs
|
||||||
|
// imports `base` from here and appends its own architecture-boundary rules.
|
||||||
|
//
|
||||||
|
// Enforced here (build fails on violation):
|
||||||
|
// - no `any` (@typescript-eslint/no-explicit-any + no-unsafe-*)
|
||||||
|
// - no type assertions (consistent-type-assertions: never → bans `x as T`)
|
||||||
|
// - no non-null `!` (no-non-null-assertion)
|
||||||
|
// - no dead optional `?.`(no-unnecessary-condition)
|
||||||
|
// - functional iteration (prefer-const, no-param-reassign, no-var)
|
||||||
|
// - no suppressions (ban-ts-comment)
|
||||||
|
// - safe promises (no-floating-promises, await-thenable)
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export const base = tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['**/dist/**', '**/build/**', '**/.expo/**', '**/coverage/**', '**/*.d.ts'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// --- no any ---
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'error',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'error',
|
||||||
|
|
||||||
|
// --- no type assertions (bans `x as T`; `as const` still allowed) ---
|
||||||
|
'@typescript-eslint/consistent-type-assertions': [
|
||||||
|
'error',
|
||||||
|
{ assertionStyle: 'never' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- no non-null assertions ---
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
|
||||||
|
// --- no error-silencing optional chaining / dead conditions ---
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||||
|
|
||||||
|
// --- functional / immutable iteration; no spaghetti ---
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-param-reassign': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
|
||||||
|
// --- no suppressions ---
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'error',
|
||||||
|
|
||||||
|
// --- safe async ---
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/await-thenable': 'error',
|
||||||
|
|
||||||
|
// --- hygiene ---
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/consistent-type-imports': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Config files themselves run outside the typed project graph.
|
||||||
|
files: ['**/*.config.{js,mjs,cjs,ts}', '**/*.setup.{js,mjs,cjs,ts}'],
|
||||||
|
...tseslint.configs.disableTypeChecked,
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default base;
|
||||||
46
infra/centrifugo/config.json
Normal file
46
infra/centrifugo/config.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/centrifugal/centrifugo/master/internal/config/schema.json",
|
||||||
|
"client": {
|
||||||
|
"allowed_origins": ["*"]
|
||||||
|
},
|
||||||
|
"http_api": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"engine": {
|
||||||
|
"type": "redis",
|
||||||
|
"redis": {
|
||||||
|
"address": "redis://redis:6379"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"namespaces": [
|
||||||
|
{
|
||||||
|
"name": "room",
|
||||||
|
"presence": true,
|
||||||
|
"join_leave": true,
|
||||||
|
"force_push_join_leave": true,
|
||||||
|
"history_size": 100,
|
||||||
|
"history_ttl": "300s",
|
||||||
|
"force_recovery": true,
|
||||||
|
"subscribe_proxy_enabled": true,
|
||||||
|
"subscribe_proxy_name": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
"presence": true,
|
||||||
|
"history_size": 100,
|
||||||
|
"history_ttl": "300s",
|
||||||
|
"force_recovery": true,
|
||||||
|
"subscribe_proxy_enabled": true,
|
||||||
|
"subscribe_proxy_name": "backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxies": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"endpoint": "http://backend:4000/centrifugo/subscribe",
|
||||||
|
"timeout": "3s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
infra/nginx/nginx.conf
Normal file
45
infra/nginx/nginx.conf
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
# Upstreams resolve to compose service names on the private network.
|
||||||
|
upstream backend {
|
||||||
|
server backend:4000;
|
||||||
|
}
|
||||||
|
upstream centrifugo {
|
||||||
|
server centrifugo:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# REST API — strip the /api prefix before proxying to the backend.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Realtime WebSocket — Centrifugo (receive-only client socket).
|
||||||
|
location /connection/websocket {
|
||||||
|
proxy_pass http://centrifugo;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "Altricade gateway — /api/* -> backend, /connection/websocket -> centrifugo\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
infra/postgres/init/.gitkeep
Normal file
2
infra/postgres/init/.gitkeep
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Optional Postgres init SQL is mounted here (docker-entrypoint-initdb.d).
|
||||||
|
# Schema is managed by node-pg-migrate migrations, not init scripts.
|
||||||
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "altricade-messenger",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.12.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.6.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-boundaries": "^7.0.2",
|
||||||
|
"globals": "^17.7.0",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"turbo": "^2.10.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.63.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": ">=0.28.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/backend/.dockerignore
Normal file
6
packages/backend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.turbo
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
43
packages/backend/Dockerfile
Normal file
43
packages/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- base: pnpm-enabled Node ----
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
ENV PNPM_HOME=/pnpm
|
||||||
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /repo
|
||||||
|
|
||||||
|
# ---- build: install workspace, bundle backend (+ core) ----
|
||||||
|
FROM base AS build
|
||||||
|
# Manifests first for better layer caching.
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||||
|
COPY packages/core/package.json packages/core/package.json
|
||||||
|
COPY packages/backend/package.json packages/backend/package.json
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
# Sources.
|
||||||
|
COPY packages/core packages/core
|
||||||
|
COPY packages/backend packages/backend
|
||||||
|
# Bundle backend + core source into packages/backend/dist.
|
||||||
|
RUN pnpm --filter @altricade/backend build
|
||||||
|
# Produce a pruned, production-only deployment (dist + migrations + prod node_modules).
|
||||||
|
RUN pnpm --filter @altricade/backend deploy --prod /app
|
||||||
|
|
||||||
|
# ---- dev: hot-reload for local development (used by docker-compose.override.yml) ----
|
||||||
|
# Inherits the full workspace + dev deps (tsx) from the build stage. Source is
|
||||||
|
# bind-mounted over this at runtime so edits reload live.
|
||||||
|
FROM build AS dev
|
||||||
|
WORKDIR /repo/packages/backend
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["pnpm", "dev"]
|
||||||
|
|
||||||
|
# ---- runtime: slim, non-root ----
|
||||||
|
FROM base AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build --chown=node:node /app /app
|
||||||
|
USER node
|
||||||
|
EXPOSE 4000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD wget -qO- "http://127.0.0.1:${BACKEND_PORT:-4000}/health" || exit 1
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
45
packages/backend/eslint.config.mjs
Normal file
45
packages/backend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
import boundaries from 'eslint-plugin-boundaries';
|
||||||
|
|
||||||
|
// Backend architecture layering. The folder-based layers are classified and the
|
||||||
|
// import direction between them is enforced:
|
||||||
|
// routes → may import plugins, db
|
||||||
|
// plugins → may import db
|
||||||
|
// db → leaf (types only)
|
||||||
|
// (src/app.ts, src/server.ts, src/config.ts are the composition root + leaf
|
||||||
|
// config — unclassified in Phase 0.) The full per-domain module structure with
|
||||||
|
// route → service → repository layering is introduced in Phase 1.
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**', 'migrations/**'] },
|
||||||
|
...base,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
plugins: { boundaries },
|
||||||
|
settings: {
|
||||||
|
'boundaries/include': ['src/**/*'],
|
||||||
|
'boundaries/elements': [
|
||||||
|
{ type: 'plugins', pattern: 'src/plugins/*' },
|
||||||
|
{ type: 'routes', pattern: 'src/routes/*' },
|
||||||
|
{ type: 'db', pattern: 'src/db/*' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'boundaries/dependencies': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
default: 'disallow',
|
||||||
|
policies: [
|
||||||
|
{
|
||||||
|
from: { element: { types: 'routes' } },
|
||||||
|
allow: { to: { element: { types: { anyOf: ['plugins', 'db'] } } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { types: 'plugins' } },
|
||||||
|
allow: { to: { element: { types: 'db' } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
13
packages/backend/migrations/1720000000000_init.cjs
Normal file
13
packages/backend/migrations/1720000000000_init.cjs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Phase 0 placeholder migration — proves the node-pg-migrate pipeline runs
|
||||||
|
// end-to-end against Postgres. The real schema (users, refresh_tokens, rooms,
|
||||||
|
// room_members, conversations, messages, reactions, read_state) lands in Phase 1.
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.createExtension('pgcrypto', { ifNotExists: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropExtension('pgcrypto', { ifExists: true });
|
||||||
|
};
|
||||||
33
packages/backend/package.json
Normal file
33
packages/backend/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "@altricade/backend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsup",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"migrate:up": "node-pg-migrate up",
|
||||||
|
"migrate:down": "node-pg-migrate down"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@altricade/core": "workspace:^",
|
||||||
|
"@fastify/cors": "^11.3.0",
|
||||||
|
"fastify": "^5.10.0",
|
||||||
|
"fastify-plugin": "^6.0.0",
|
||||||
|
"ioredis": "^5.11.1",
|
||||||
|
"kysely": "^0.29.3",
|
||||||
|
"minio": "^8.0.7",
|
||||||
|
"node-pg-migrate": "^8.0.4",
|
||||||
|
"pg": "^8.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.1",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/backend/src/app.ts
Normal file
39
packages/backend/src/app.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import { roomChannel } from '@altricade/core';
|
||||||
|
import { loadConfig } from './config';
|
||||||
|
import type { AppConfig } from './config';
|
||||||
|
import { dbPlugin } from './plugins/db';
|
||||||
|
import { redisPlugin } from './plugins/redis';
|
||||||
|
import { minioPlugin } from './plugins/minio';
|
||||||
|
import { centrifugoPlugin } from './plugins/centrifugo';
|
||||||
|
import { healthRoutes } from './routes/health';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
config: AppConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composition root: assemble the Fastify app from config + plugins + routes.
|
||||||
|
// Kept side-effect-free (no listen) so it can be reused by tests later.
|
||||||
|
export const buildApp = async (config: AppConfig = loadConfig()): Promise<FastifyInstance> => {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.nodeEnv === 'production' ? 'info' : 'debug' },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate('config', config);
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true, credentials: true });
|
||||||
|
await app.register(dbPlugin);
|
||||||
|
await app.register(redisPlugin);
|
||||||
|
await app.register(minioPlugin);
|
||||||
|
await app.register(centrifugoPlugin);
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
|
||||||
|
// Proves the @altricade/core shared kernel is imported and callable.
|
||||||
|
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
};
|
||||||
79
packages/backend/src/config.ts
Normal file
79
packages/backend/src/config.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Typed, validated environment configuration. All backend config comes from env
|
||||||
|
// (12-factor); missing or malformed required values fail fast at startup rather
|
||||||
|
// than surfacing as confusing runtime errors. No `any`, no assertions.
|
||||||
|
|
||||||
|
export interface MinioConfig {
|
||||||
|
endpoint: string;
|
||||||
|
port: number;
|
||||||
|
useSSL: boolean;
|
||||||
|
accessKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
buckets: {
|
||||||
|
media: string;
|
||||||
|
avatars: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CentrifugoConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
tokenHmacSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
nodeEnv: string;
|
||||||
|
port: number;
|
||||||
|
databaseUrl: string;
|
||||||
|
redisUrl: string;
|
||||||
|
minio: MinioConfig;
|
||||||
|
centrifugo: CentrifugoConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = (name: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const optional = (name: string, fallback: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePort = (name: string, raw: string): number => {
|
||||||
|
const port = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
||||||
|
throw new Error(`Environment variable ${name} is not a valid port: ${raw}`);
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
export const loadConfig = (): AppConfig => ({
|
||||||
|
nodeEnv: optional('NODE_ENV', 'development'),
|
||||||
|
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
||||||
|
databaseUrl: required('DATABASE_URL'),
|
||||||
|
redisUrl: required('REDIS_URL'),
|
||||||
|
minio: {
|
||||||
|
endpoint: required('MINIO_ENDPOINT'),
|
||||||
|
port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')),
|
||||||
|
useSSL: parseBoolean(optional('MINIO_USE_SSL', 'false')),
|
||||||
|
accessKey: required('MINIO_ROOT_USER'),
|
||||||
|
secretKey: required('MINIO_ROOT_PASSWORD'),
|
||||||
|
buckets: {
|
||||||
|
media: optional('MINIO_BUCKET_MEDIA', 'media'),
|
||||||
|
avatars: optional('MINIO_BUCKET_AVATARS', 'avatars'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
centrifugo: {
|
||||||
|
apiUrl: required('CENTRIFUGO_API_URL'),
|
||||||
|
apiKey: required('CENTRIFUGO_API_KEY'),
|
||||||
|
tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'),
|
||||||
|
},
|
||||||
|
});
|
||||||
4
packages/backend/src/db/schema.ts
Normal file
4
packages/backend/src/db/schema.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// The Kysely database registry: one property per table. Empty in Phase 0 —
|
||||||
|
// tables (users, refresh_tokens, rooms, messages, ...) are added here in lockstep
|
||||||
|
// with node-pg-migrate migrations from Phase 1 onward.
|
||||||
|
export type Database = Record<string, never>;
|
||||||
41
packages/backend/src/plugins/centrifugo.ts
Normal file
41
packages/backend/src/plugins/centrifugo.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
|
||||||
|
export interface CentrifugoClient {
|
||||||
|
/** Publish an event payload into a channel via Centrifugo's server HTTP API. */
|
||||||
|
publish(channel: string, data: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
centrifugo: CentrifugoClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin wrapper over Centrifugo's server HTTP API. Centrifugo is a dumb pipe: the
|
||||||
|
// backend is the only publisher. Wired now; actually used from Phase 2 onward.
|
||||||
|
export const centrifugoPlugin = fp(
|
||||||
|
(app) => {
|
||||||
|
const { apiUrl, apiKey } = app.config.centrifugo;
|
||||||
|
|
||||||
|
const client: CentrifugoClient = {
|
||||||
|
async publish(channel, data) {
|
||||||
|
const response = await fetch(`${apiUrl}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ channel, data }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Centrifugo publish failed with status ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
app.decorate('centrifugo', client);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
{ name: 'centrifugo' },
|
||||||
|
);
|
||||||
27
packages/backend/src/plugins/db.ts
Normal file
27
packages/backend/src/plugins/db.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { Kysely, PostgresDialect } from 'kysely';
|
||||||
|
import type { Database } from '../db/schema';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
db: Kysely<Database>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postgres connection pool wrapped in a typed Kysely instance. Registered via
|
||||||
|
// fastify-plugin so the `db` decoration is visible app-wide, not just in scope.
|
||||||
|
export const dbPlugin = fp(
|
||||||
|
(app) => {
|
||||||
|
const pool = new pg.Pool({ connectionString: app.config.databaseUrl });
|
||||||
|
const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
|
||||||
|
|
||||||
|
app.decorate('db', db);
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
{ name: 'db' },
|
||||||
|
);
|
||||||
29
packages/backend/src/plugins/minio.ts
Normal file
29
packages/backend/src/plugins/minio.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { Client } from 'minio';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
minio: Client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3-compatible object storage client (media messages + avatars). Buckets are
|
||||||
|
// bootstrapped by the compose `minio-setup` one-shot; this client is used for
|
||||||
|
// readiness checks now and presigned uploads from Phase 6.
|
||||||
|
export const minioPlugin = fp(
|
||||||
|
(app) => {
|
||||||
|
const { endpoint, port, useSSL, accessKey, secretKey } = app.config.minio;
|
||||||
|
const client = new Client({
|
||||||
|
endPoint: endpoint,
|
||||||
|
port,
|
||||||
|
useSSL,
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate('minio', client);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
{ name: 'minio' },
|
||||||
|
);
|
||||||
27
packages/backend/src/plugins/redis.ts
Normal file
27
packages/backend/src/plugins/redis.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
redis: Redis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis client. Present from Phase 0 as the coordination layer Centrifugo (and
|
||||||
|
// later LiveKit) clustering depends on; also used for auth rate-limiting later.
|
||||||
|
export const redisPlugin = fp(
|
||||||
|
(app) => {
|
||||||
|
const redis = new Redis(app.config.redisUrl, {
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
lazyConnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate('redis', redis);
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
await redis.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
{ name: 'redis' },
|
||||||
|
);
|
||||||
86
packages/backend/src/routes/health.ts
Normal file
86
packages/backend/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
interface ReadyReport {
|
||||||
|
status: 'ready' | 'degraded';
|
||||||
|
checks: {
|
||||||
|
postgres: boolean;
|
||||||
|
redis: boolean;
|
||||||
|
minio: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound each dependency probe so /ready always answers promptly, even when a
|
||||||
|
// backing service hangs (e.g. a disconnected client queuing commands rather than
|
||||||
|
// failing fast). A probe that does not resolve in time counts as not-ready.
|
||||||
|
const PROBE_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
const withTimeout = async (probe: Promise<boolean>): Promise<boolean> => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<boolean>((resolve) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
}, PROBE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([probe, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkPostgres = async (app: FastifyInstance): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await sql`select 1`.execute(app.db);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ error }, 'postgres readiness check failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkRedis = async (app: FastifyInstance): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await app.redis.ping();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ error }, 'redis readiness check failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMinio = async (app: FastifyInstance): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await app.minio.listBuckets();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ error }, 'minio readiness check failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// /health — liveness: the process is up and serving (used by Docker HEALTHCHECK).
|
||||||
|
// /ready — readiness: every backing service is reachable (pg + redis + minio).
|
||||||
|
export const healthRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
|
app.get('/health', () => ({ status: 'ok' }));
|
||||||
|
|
||||||
|
app.get('/ready', async (_request, reply) => {
|
||||||
|
const [postgres, redis, minio] = await Promise.all([
|
||||||
|
withTimeout(checkPostgres(app)),
|
||||||
|
withTimeout(checkRedis(app)),
|
||||||
|
withTimeout(checkMinio(app)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ready = postgres && redis && minio;
|
||||||
|
const report: ReadyReport = {
|
||||||
|
status: ready ? 'ready' : 'degraded',
|
||||||
|
checks: { postgres, redis, minio },
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.code(ready ? 200 : 503).send(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
29
packages/backend/src/server.ts
Normal file
29
packages/backend/src/server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { buildApp } from './app';
|
||||||
|
import { loadConfig } from './config';
|
||||||
|
|
||||||
|
const start = async (): Promise<void> => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const app = await buildApp(config);
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
app.log.info(`received ${signal}, shutting down gracefully`);
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void shutdown('SIGTERM');
|
||||||
|
});
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void shutdown('SIGINT');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host: '0.0.0.0', port: config.port });
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void start();
|
||||||
8
packages/backend/tsconfig.json
Normal file
8
packages/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
packages/backend/tsup.config.ts
Normal file
15
packages/backend/tsup.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
// Bundle the backend (and the @altricade/core source it imports) into a single
|
||||||
|
// self-contained ESM output for the slim runtime image. Third-party deps stay
|
||||||
|
// external and are installed as production deps in the Docker runtime stage.
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/server.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
target: 'node22',
|
||||||
|
platform: 'node',
|
||||||
|
outDir: 'dist',
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
noExternal: [/^@altricade\//],
|
||||||
|
});
|
||||||
3
packages/core/eslint.config.mjs
Normal file
3
packages/core/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default base;
|
||||||
17
packages/core/package.json
Normal file
17
packages/core/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "@altricade/core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./channels": "./src/channels/index.ts",
|
||||||
|
"./events": "./src/events/index.ts",
|
||||||
|
"./types": "./src/types/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint ."
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/core/src/channels/index.ts
Normal file
21
packages/core/src/channels/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Channel-name builders — the single source of truth for Centrifugo channel
|
||||||
|
// names, shared by backend (publish/subscribe-proxy) and every client.
|
||||||
|
//
|
||||||
|
// room:<roomId> — one channel per room; all members subscribe.
|
||||||
|
// user:<userId> — one personal channel per user; DM + notification delivery.
|
||||||
|
|
||||||
|
export type RoomId = string;
|
||||||
|
export type UserId = string;
|
||||||
|
|
||||||
|
const ROOM_PREFIX = 'room:';
|
||||||
|
const USER_PREFIX = 'user:';
|
||||||
|
|
||||||
|
export const roomChannel = (roomId: RoomId): string => `${ROOM_PREFIX}${roomId}`;
|
||||||
|
|
||||||
|
export const userChannel = (userId: UserId): string => `${USER_PREFIX}${userId}`;
|
||||||
|
|
||||||
|
/** True for a `room:<id>` channel name. */
|
||||||
|
export const isRoomChannel = (channel: string): boolean => channel.startsWith(ROOM_PREFIX);
|
||||||
|
|
||||||
|
/** True for a `user:<id>` channel name. */
|
||||||
|
export const isUserChannel = (channel: string): boolean => channel.startsWith(USER_PREFIX);
|
||||||
31
packages/core/src/events/index.ts
Normal file
31
packages/core/src/events/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Realtime event taxonomy (spec section 3). Every live feature is an event on
|
||||||
|
// the same receive socket; these names are the contract between backend
|
||||||
|
// publishers and client handlers. Payload shapes are added alongside features.
|
||||||
|
//
|
||||||
|
// Bucket A — durable, must be correct → through the backend (persist, publish)
|
||||||
|
// Bucket B — ephemeral, throwaway → published, not persisted
|
||||||
|
// Bucket C — connection-derived → Centrifugo built-in presence
|
||||||
|
|
||||||
|
export const EventType = {
|
||||||
|
// Bucket A
|
||||||
|
MessageNew: 'message.new',
|
||||||
|
MessageEdit: 'message.edit',
|
||||||
|
MessageDelete: 'message.delete',
|
||||||
|
ReactionAdd: 'reaction.add',
|
||||||
|
ReactionRemove: 'reaction.remove',
|
||||||
|
ReadReceipt: 'read.receipt',
|
||||||
|
LastSeen: 'last_seen',
|
||||||
|
RoomMembership: 'room.membership',
|
||||||
|
ProfileUpdate: 'profile.update',
|
||||||
|
|
||||||
|
// Bucket B
|
||||||
|
TypingStart: 'typing.start',
|
||||||
|
TypingStop: 'typing.stop',
|
||||||
|
RecordingAudio: 'recording.audio',
|
||||||
|
RecordingVideo: 'recording.video',
|
||||||
|
|
||||||
|
// Bucket C
|
||||||
|
Presence: 'presence',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||||
4
packages/core/src/index.ts
Normal file
4
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Public API of the @altricade/core shared kernel.
|
||||||
|
export * from './channels/index';
|
||||||
|
export * from './events/index';
|
||||||
|
export * from './types/index';
|
||||||
20
packages/core/src/types/index.ts
Normal file
20
packages/core/src/types/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Shared payload/domain types. Placeholder shapes for Phase 0 — real message /
|
||||||
|
// user / API types (and their JSON Schemas) are filled in per feature phase.
|
||||||
|
|
||||||
|
/** Bumped as `core` evolves; clients can log/verify the shared contract version. */
|
||||||
|
export const CORE_VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
7
packages/core/tsconfig.json
Normal file
7
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
3
packages/desktop/eslint.config.mjs
Normal file
3
packages/desktop/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default base;
|
||||||
13
packages/desktop/package.json
Normal file
13
packages/desktop/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "@altricade/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@altricade/core": "workspace:^"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/desktop/src/index.ts
Normal file
9
packages/desktop/src/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Phase 0 placeholder. The desktop client is a thin Tauri shell wrapping the
|
||||||
|
// @altricade/web build (macOS + Windows), built in Phase 7. No separate UI —
|
||||||
|
// it loads the same web app, so it inherits web's FSD structure, theming, and
|
||||||
|
// offline-first behavior.
|
||||||
|
|
||||||
|
import { CORE_VERSION } from '@altricade/core';
|
||||||
|
|
||||||
|
export const desktopPlaceholder = (): string =>
|
||||||
|
`@altricade/desktop (Tauri shell) stub on core ${CORE_VERSION}`;
|
||||||
7
packages/desktop/tsconfig.json
Normal file
7
packages/desktop/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
3
packages/mobile/eslint.config.mjs
Normal file
3
packages/mobile/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default base;
|
||||||
13
packages/mobile/package.json
Normal file
13
packages/mobile/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "@altricade/mobile",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@altricade/core": "workspace:^"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/mobile/src/index.ts
Normal file
14
packages/mobile/src/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Phase 0 placeholder. The real React Native + Expo app is built in Phase 7,
|
||||||
|
// reusing the @altricade/core realtime/API/auth layer with platform-specific UI
|
||||||
|
// following the same Feature-Sliced Design layers as web
|
||||||
|
// (app → screens → widgets → features → entities → shared).
|
||||||
|
//
|
||||||
|
// Decisions locked for Phase 7:
|
||||||
|
// - Virtualized lists use @legendapp/list (LegendList), NOT FlatList.
|
||||||
|
// - Offline-first: persistent outbox + local cache, shared logic from core.
|
||||||
|
// - Light/dark theming from shared design tokens (mirrors web/src/shared/theme).
|
||||||
|
|
||||||
|
import { CORE_VERSION, userChannel } from '@altricade/core';
|
||||||
|
|
||||||
|
export const mobilePlaceholder = (): string =>
|
||||||
|
`@altricade/mobile stub on core ${CORE_VERSION}; personal channel ${userChannel('me')}`;
|
||||||
7
packages/mobile/tsconfig.json
Normal file
7
packages/mobile/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
44
packages/web/eslint.config.mjs
Normal file
44
packages/web/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { base } from '../../eslint.config.mjs';
|
||||||
|
import boundaries from 'eslint-plugin-boundaries';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
// Strict Feature-Sliced Design: a layer may import only from itself and the
|
||||||
|
// layers below it. Upward imports (e.g. shared → features) are build errors.
|
||||||
|
const FSD_LAYERS = ['app', 'pages', 'widgets', 'features', 'entities', 'shared'];
|
||||||
|
|
||||||
|
const fsdPolicies = FSD_LAYERS.map((layer, index) => ({
|
||||||
|
from: { element: { types: layer } },
|
||||||
|
allow: { to: { element: { types: { anyOf: FSD_LAYERS.slice(index) } } } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**'] },
|
||||||
|
...base,
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
boundaries,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'boundaries/include': ['src/**/*'],
|
||||||
|
'boundaries/elements': [
|
||||||
|
{ type: 'app', pattern: 'src/app/*' },
|
||||||
|
{ type: 'pages', pattern: 'src/pages/*' },
|
||||||
|
{ type: 'widgets', pattern: 'src/widgets/*' },
|
||||||
|
{ type: 'features', pattern: 'src/features/*' },
|
||||||
|
{ type: 'entities', pattern: 'src/entities/*' },
|
||||||
|
{ type: 'shared', pattern: 'src/shared/*' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'boundaries/dependencies': ['error', { default: 'disallow', policies: fsdPolicies }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
12
packages/web/index.html
Normal file
12
packages/web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Altricade</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/app/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
packages/web/package.json
Normal file
26
packages/web/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@altricade/web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@altricade/core": "workspace:^",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/web/src/app/App.tsx
Normal file
44
packages/web/src/app/App.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { CORE_VERSION, roomChannel } from '@altricade/core';
|
||||||
|
import { useTheme } from '../shared/theme';
|
||||||
|
import type { ThemePreference } from '../shared/theme';
|
||||||
|
import { env } from '../shared/config';
|
||||||
|
|
||||||
|
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
|
||||||
|
|
||||||
|
export const App = (): ReactElement => {
|
||||||
|
const { preference, resolved, setPreference } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="app">
|
||||||
|
<h1>Altricade</h1>
|
||||||
|
<p>Realtime chat & calls — Phase 0 skeleton.</p>
|
||||||
|
<dl>
|
||||||
|
<dt>core version</dt>
|
||||||
|
<dd>{CORE_VERSION}</dd>
|
||||||
|
<dt>example channel</dt>
|
||||||
|
<dd>{roomChannel('demo')}</dd>
|
||||||
|
<dt>API base</dt>
|
||||||
|
<dd>{env.apiUrl}</dd>
|
||||||
|
<dt>theme</dt>
|
||||||
|
<dd>
|
||||||
|
{resolved} (preference: {preference})
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="theme-switch">
|
||||||
|
{PREFERENCES.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={preference === option}
|
||||||
|
onClick={() => {
|
||||||
|
setPreference(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
packages/web/src/app/index.css
Normal file
75
packages/web/src/app/index.css
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
/* Fallbacks until ThemeProvider sets the resolved tokens on <html>. */
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-surface: #f4f5f7;
|
||||||
|
--color-text: #0b0c0f;
|
||||||
|
--color-textMuted: #5b6472;
|
||||||
|
--color-accent: #2f6fed;
|
||||||
|
--color-border: #e2e5ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app h1 {
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app dt {
|
||||||
|
color: var(--color-textMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app dd {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switch button[aria-pressed='true'] {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
18
packages/web/src/app/main.tsx
Normal file
18
packages/web/src/app/main.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '../shared/theme';
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container === null) {
|
||||||
|
throw new Error('Root container #root not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
9
packages/web/src/shared/config/env.ts
Normal file
9
packages/web/src/shared/config/env.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Web runtime config. Vite inlines `import.meta.env.VITE_*` at build time.
|
||||||
|
|
||||||
|
export interface WebEnv {
|
||||||
|
apiUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env: WebEnv = {
|
||||||
|
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:8080',
|
||||||
|
};
|
||||||
2
packages/web/src/shared/config/index.ts
Normal file
2
packages/web/src/shared/config/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { env } from './env';
|
||||||
|
export type { WebEnv } from './env';
|
||||||
71
packages/web/src/shared/theme/ThemeProvider.tsx
Normal file
71
packages/web/src/shared/theme/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import { themes } from './tokens';
|
||||||
|
import type { ThemeName } from './tokens';
|
||||||
|
|
||||||
|
export type ThemePreference = ThemeName | 'system';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
preference: ThemePreference;
|
||||||
|
resolved: ThemeName;
|
||||||
|
setPreference: (preference: ThemePreference) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'altricade.theme';
|
||||||
|
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
|
const readStoredPreference = (): ThemePreference => {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSystemTheme = (): ThemeName =>
|
||||||
|
window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light';
|
||||||
|
|
||||||
|
const applyTheme = (theme: ThemeName): void => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset['theme'] = theme;
|
||||||
|
const colors: Record<string, string> = themes[theme];
|
||||||
|
for (const [token, value] of Object.entries(colors)) {
|
||||||
|
root.style.setProperty(`--color-${token}`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: { children: ReactNode }): ReactElement => {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(readStoredPreference);
|
||||||
|
const [systemTheme, setSystemTheme] = useState<ThemeName>(readSystemTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(DARK_QUERY);
|
||||||
|
const onChange = (): void => {
|
||||||
|
setSystemTheme(media.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
media.addEventListener('change', onChange);
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener('change', onChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolved: ThemeName = preference === 'system' ? systemTheme : preference;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(resolved);
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
const setPreference = useCallback((next: ThemePreference): void => {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
setPreferenceState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ThemeContextValue>(
|
||||||
|
() => ({ preference, resolved, setPreference }),
|
||||||
|
[preference, resolved, setPreference],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
};
|
||||||
5
packages/web/src/shared/theme/index.ts
Normal file
5
packages/web/src/shared/theme/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { ThemeProvider } from './ThemeProvider';
|
||||||
|
export { useTheme } from './useTheme';
|
||||||
|
export { themes } from './tokens';
|
||||||
|
export type { ThemeName } from './tokens';
|
||||||
|
export type { ThemePreference, ThemeContextValue } from './ThemeProvider';
|
||||||
33
packages/web/src/shared/theme/tokens.ts
Normal file
33
packages/web/src/shared/theme/tokens.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Design tokens — the single source of truth for colors. No hard-coded colors
|
||||||
|
// live in feature/entity/widget code; everything reads these via CSS variables.
|
||||||
|
|
||||||
|
export type ThemeName = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ThemeColors {
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
accent: string;
|
||||||
|
border: string;
|
||||||
|
[token: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themes: Record<ThemeName, ThemeColors> = {
|
||||||
|
light: {
|
||||||
|
background: '#ffffff',
|
||||||
|
surface: '#f4f5f7',
|
||||||
|
text: '#0b0c0f',
|
||||||
|
textMuted: '#5b6472',
|
||||||
|
accent: '#2f6fed',
|
||||||
|
border: '#e2e5ea',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: '#0b0c0f',
|
||||||
|
surface: '#15171c',
|
||||||
|
text: '#f4f5f7',
|
||||||
|
textMuted: '#9aa3b2',
|
||||||
|
accent: '#5b8bff',
|
||||||
|
border: '#242833',
|
||||||
|
},
|
||||||
|
};
|
||||||
11
packages/web/src/shared/theme/useTheme.ts
Normal file
11
packages/web/src/shared/theme/useTheme.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext } from './ThemeProvider';
|
||||||
|
import type { ThemeContextValue } from './ThemeProvider';
|
||||||
|
|
||||||
|
export const useTheme = (): ThemeContextValue => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
9
packages/web/src/vite-env.d.ts
vendored
Normal file
9
packages/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
9
packages/web/tsconfig.json
Normal file
9
packages/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
packages/web/vite.config.ts
Normal file
10
packages/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
3853
pnpm-lock.yaml
Normal file
3853
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
417
specs.md
Normal file
417
specs.md
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
# 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.
|
||||||
31
tsconfig.base.json
Normal file
31
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
|
||||||
|
"sourceMap": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
20
turbo.json
Normal file
20
turbo.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"ui": "stream",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue