Self-hosted Discord alternative. Phase 1 (MVP core) in progress.
Client → Edge (:60420) ─── /id/* ──→ Identity (:8081)
└── /api/* → Official API (:8080)
└── /ws → WebSocket Hub (NATS-backed)
NATS JetStream ← Official API publishes events
NATS JetStream → Edge WS Hub → WebSocket clients
- Edge (:60420) is the single entry point for all client traffic — REST and WebSocket.
/id/*— proxied to Identity (Authorization header forwarded, no JWT check)/api/*— proxied to Official (JWT validated, X-User-ID injected, Authorization stripped)/ws— WebSocket realtime gateway
- Official and Identity are internal; never call them directly from clients.
- Identity issues RS256 JWTs; Edge validates them offline via JWKS cache.
- CORS is configured via
DEV_ALLOWED_ORIGINS(default:http://localhost:5173).
make identity-keygen
# Generates infra/keys/identity_rsa.pem (RSA 2048, chmod 600).
# The identity service loads this key so JWT sessions survive restarts.
# Idempotent — safe to re-run; skips if the file already exists.
# Also set IDENTITY_RSA_PRIVATE_KEY_FILE=infra/keys/identity_rsa.pem in .env
# (already the default in infra/.env.example).make dev-up
# Starts: Postgres 16, Redis 7, NATS 2.10, MinIO, runs DB migrations.make minio-init
# Creates the storage bucket and sets the browser-upload CORS policy.
# Safe to re-run at any time (idempotent).make run-identity # :8081
make run-official # :8080
make run-edge # :60420Env vars are loaded from .env at the repo root (copied from infra/.env.example).
From P1.7 on: speak only to the Edge. Replace any
:8081or:8080calls with:60420/id/or:60420/api/.
# Register
curl -sf -X POST http://localhost:60420/id/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","username":"alice","password":"S3cretPass!"}' | jq .
# Login → always returns partial_token (2FA mandatory)
set PARTIAL (curl -sf -X POST http://localhost:60420/id/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"S3cretPass!"}' | jq -r .partial_token)
# Setup 2FA (first login)
set SETUP (curl -sf -X POST http://localhost:60420/id/auth/2fa/setup \
-H "Authorization: Bearer $PARTIAL")
set SECRET (echo $SETUP | jq -r .totp_secret)
# Get TOTP code (requires oathtool: pacman -S oath-toolkit)
set CODE (oathtool --totp --base32 $SECRET)
# Verify → full access_token
set ACCESS (curl -sf -X POST http://localhost:60420/id/auth/2fa/verify \
-H "Authorization: Bearer $PARTIAL" \
-H 'Content-Type: application/json' \
-d "{\"code\":\"$CODE\"}" | jq -r .access_token)# Create server
set SID (curl -sf -X POST http://localhost:60420/api/servers \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"name":"My Server"}' | jq -r .id)
# My servers
curl -sf http://localhost:60420/api/me/servers \
-H "Authorization: Bearer $ACCESS" | jq .
# Create channel
set CID (curl -sf -X POST http://localhost:60420/api/servers/$SID/channels \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"name":"general"}' | jq -r .id)
# Send message
set MID (curl -sf -X POST http://localhost:60420/api/channels/$CID/messages \
-H "Authorization: Bearer $ACCESS" \
-H 'Content-Type: application/json' \
-d '{"content":"Hello!"}' | jq -r .id)
# List messages (newest first)
curl -sf "http://localhost:60420/api/channels/$CID/messages" \
-H "Authorization: Bearer $ACCESS" | jq '.messages[].content'
# Delete message (204 No Content)
curl -sf -X DELETE http://localhost:60420/api/messages/$MID \
-H "Authorization: Bearer $ACCESS" -o /dev/null -w "%{http_code}\n"make web-install # bun install (first time)
make web-dev # starts Vite on http://localhost:5173The web client speaks only to the Edge. CORS is pre-configured for http://localhost:5173.
# Connect (token in query param — standard for WS clients)
websocat "ws://localhost:60420/ws?token=$ACCESS"
# ← {"op":"HELLO","d":{"heartbeat_interval":30000}}
# Subscribe (send after connect):
# {"op":"SUBSCRIBE","d":{"server_id":"<SID>","channel_ids":["<CID>"]}}
# ← {"op":"EVENT","t":"PRESENCE_UPDATE","d":{"status":"online",...}}
# Now POST a message in another terminal → WS receives MESSAGE_CREATE event
# POST /channels/:id/typing → WS receives TYPING_START event
# DELETE /messages/:id → WS receives MESSAGE_DELETE eventSee docs/DEV_SMOKE_TESTS.md for the full step-by-step checklist including exact expected payloads.
| Target | Description |
|---|---|
make identity-keygen |
Generate RSA key for identity service (once, idempotent) |
make dev-up |
Start infra (Postgres + Redis + NATS + MinIO + migrations) |
make minio-init |
Create MinIO bucket + CORS policy (run once after make dev-up) |
make dev-down |
Stop infra |
make dev-up-all |
Start infra + app services via Docker build |
make run-identity |
Run identity service locally (reads .env) |
make run-official |
Run official API locally |
make run-edge |
Run edge proxy + WS hub locally |
make build-all |
Build all three binaries to bin/ |
make gen-permissions |
Regenerate Go + TS permission constants from permissions.json |
make dev-delete-user EMAIL=x@y.z |
Delete one user + all their data from the local dev DB (irreversible) |
make dev-reset-db |
Truncate every app table (prompts for confirmation; schema/migrations untouched) |
make test |
Run all tests |
| Service | Port | Notes |
|---|---|---|
| Edge | 60420 | Public-facing (REST + WebSocket) |
| Official API | 8080 | Internal only |
| Identity | 8081 | Internal only |
| PostgreSQL | 5432 | |
| Redis | 6379 | |
| NATS | 4222 | JetStream enabled |
| MinIO | 9000 / 9001 | S3-compat API / console |
dev-up.sh handles both automatically:
1. Wrong Docker context (desktop-linux instead of default) — causes /host_mnt mount
failures and compose errors. The script detects and aborts with a clear fix hint:
# Auto-fix (switches context + restarts Docker daemon if systemd-managed):
./scripts/dev-up.sh --force
# Manual fix:
docker context use default2. Busy infra ports (e.g. port 4222 held by another NATS instance) — the script auto-remaps to alternative host ports instead of failing:
[info] Port 4222 (NATS client): held by 'nats-server' (pid 1234) → remapping to 14222
[info] Port 9000 (MinIO API): held by Docker container 'old-minio' → remapping to 19000
── Active port remaps ──────────────────────────────────────────
nats 4222 → 14222 NATS_URL updated
minio 9000 → 19000 MINIO_ENDPOINT / STORAGE_ENDPOINT updated
────────────────────────────────────────────────────────────────
Remaps are host-port only — internal Docker networking (nats:4222 etc.) is unchanged.
Go services in this run inherit the updated env vars automatically.
The generated override file (infra/docker-compose.override.autogen.yml) is removed by
dev-down.sh.
# Force-kill any leftover twopointo process on ports 8081/8080/60420/5173,
# then start fresh:
./scripts/dev-up.sh --force # bash
fish scripts/dev-up.fish --force # fish
# Or just clear ports without starting:
./scripts/dev-down.sh --force
fish scripts/dev-down.fish --forceOnly kills processes whose cmdline matches identity, official, edge, or vite/node.
Unrelated processes on the same port are skipped with a warning.
make dev-delete-user EMAIL=alice@example.comDeletes the user and all their data (servers, messages, assets, 2FA, refresh tokens) in one transaction. After deletion, register the same address again via the web UI or curl.
make dev-reset-db
# Type RESET when promptedTruncates every app table. The schema stays intact — no make migrate-up needed.
All services can keep running; just re-register users as usual.
Identity uses an RSA key to sign JWTs. Without a persistent key file, a new ephemeral key is generated on every startup, invalidating all existing tokens.
Fix (one-time):
make identity-keygen
# Then ensure your .env contains:
# IDENTITY_RSA_PRIVATE_KEY_FILE=infra/keys/identity_rsa.pemAfter this, restarting identity no longer invalidates sessions.
Migration 006 (recovery_confirmed_at column) has not been applied:
make migrate-upThen complete one login + confirm recovery codes; subsequent logins will go straight to the TOTP entry screen.