A production-grade credit-education platform: TypeScript API gateway, PostgreSQL persistence, static HTML frontend, deployed to a single VPS behind nginx + Let's Encrypt.
cd api-gateway
npm install
npm test # 28 tests, ~5s
npm run dev # http://localhost:3000In demo mode (no LINODE_DB_* env vars), the gateway runs entirely in
memory: register / login / enroll / progress all work without a database.
sudo DOMAIN=krai.example.com EMAIL=you@example.com \
LINODE_DB_HOST=... LINODE_DB_USER=... LINODE_DB_PASSWORD=... \
./deploy-vps.shThe script is idempotent: installs Node 20, nginx, certbot, ufw, fail2ban;
creates the krai system user; clones into /opt/krai; generates fresh JWT
secrets into /etc/krai/gateway.env (mode 0640); runs migrations; installs
the systemd unit and nginx vhost; obtains an SSL cert; locks down the
firewall.
api-gateway/ Express + zod gateway — primary entry point
src/
config/ zod-validated env loader
db/ pg pool + sequential SQL migrator
middleware/ requestId, helmet+cors, auth, rate-limit, validate,
metrics, errorHandler
routes/ health, auth, courses, progress, quiz,
leaderboard, analytics
services/ passwords (bcrypt), tokens (JWT), course/user/progress
repos (PG with in-memory fallback)
tests/ jest unit + supertest integration (28 tests)
infrastructure/
migrations/ canonical SQL schema
nginx/ krai.conf vhost + krai-proxy.conf snippet
systemd/ hardened krai-gateway.service unit
backup/ daily PG backup script (cron-friendly)
monitoring/ prometheus.yml
docker/
docker-compose.prod.yml gateway + Postgres + optional Grafana stack
frontend/ static HTML (served by the gateway)
data/courses-full.json canonical course catalog
docs/ architecture + API reference
deploy-vps.sh one-shot Ubuntu VPS provisioner
- bcrypt password hashing (rounds=12 in prod, 4 in tests); legacy SHA256 hashes are rejected outright.
- Separate
JWT_SECRETandJWT_REFRESH_SECRET, each ≥32 chars, validated at boot — the process exits in production if either is missing. - helmet with strict CSP in production, HSTS preload,
x-powered-byoff. - CORS allowlist via
CORS_ORIGINS(comma-separated), never*by default. express-rate-limitglobally plus tighter limits on/api/v1/auth/*.zodvalidation on every body/query (no unchecked JSON).- Account lockout after 5 failed logins within 15 minutes.
- Request IDs (UUID v4) on every response; structured JSON logs via winston in production.
- Prometheus
/metrics(default + per-route latency histogram); nginx restricts the endpoint to private IPs. - Graceful shutdown on SIGTERM/SIGINT; uncaught-exception handlers.
- systemd unit hardened (
NoNewPrivileges,ProtectSystem=strict,CapabilityBoundingSet=,MemoryDenyWriteExecute, syscall filter).
cd api-gateway
npm run lint # eslint, must pass
npm run typecheck # tsc --noEmit, must pass
npm test # jest, must pass
npm run build # tsc → dist/CI (.github/workflows/ci.yml) runs lint + typecheck + test + build + npm
audit + Trivy filesystem scan + Docker image smoke-test on every push.
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
development |
one of development, test, staging, production |
PORT |
3000 |
|
JWT_SECRET |
— | required, ≥32 chars |
JWT_REFRESH_SECRET |
— | required, ≥32 chars |
BCRYPT_ROUNDS |
12 |
|
CORS_ORIGINS |
http://localhost:3000,http://localhost:8000 |
comma-separated |
TRUST_PROXY |
false |
set to true behind nginx |
RATE_LIMIT_WINDOW_MS |
900000 |
15 minutes |
RATE_LIMIT_MAX |
300 |
per IP per window |
AUTH_RATE_LIMIT_MAX |
20 |
per IP per window for /auth/* |
DEMO_MODE |
false |
in-memory persistence when true |
LINODE_DB_HOST |
— | DB connection (Postgres) |
LINODE_DB_PORT |
5432 |
|
LINODE_DB_USER |
— | |
LINODE_DB_PASSWORD |
— | |
LINODE_DB_NAME |
guap_finance |
|
LINODE_DB_SSL |
true |
|
LOG_LEVEL |
info |
|
LOG_FORMAT |
json |
pretty in dev |
See api-gateway/src/config/env.ts for the canonical zod schema.
docs/architecture.md— system designdocs/api-reference.md— REST endpointsCONTRIBUTING.md— development guidelinesCLAUDE.md— internal repository guide
MIT.