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