417 lines
24 KiB
Markdown
417 lines
24 KiB
Markdown
# Project Spec — Realtime Chat & Calls App
|
|
|
|
> This document is a brief for Claude Code (plan mode). Read it fully, then propose a
|
|
> plan before writing any code. Build the skeleton first, then implement part by part
|
|
> with check-ins. Do NOT scaffold the whole thing in one pass. See "How to work" (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.
|