# ForumZ — Distributed Forum (Dsys)

A 2000s-style internet forum built as a distributed systems demo.

**Architecture**: Nginx LB → 3× Go/Chi backend replicas → PostgreSQL | React frontend

---

## Requirements

- [Docker Desktop](https://docs.docker.com/get-docker/) (includes Docker Compose v2)
- For load testing: [k6](https://grafana.com/docs/k6/latest/get-started/installation/)

---

## Quick Start

```bash
# Clone and start everything (builds images, runs DB migrations, seeds categories)
git clone <repo-url>
cd Dsys
docker compose up --build
```

Open **http://localhost** in your browser.

---

## What Starts

| Container      | Role                                | Port     |
|----------------|-------------------------------------|----------|
| `db`           | PostgreSQL 18                       | internal |
| `backend1/2/3` | Go/Chi API replicas                 | internal |
| `loadbalancer` | Nginx round-robin LB                | **:80**  |
| `frontend`     | React SPA (Nginx static)            | internal |

DB migrations run automatically on backend startup. A PostgreSQL advisory lock (`pg_advisory_lock`) prevents conflicts when all 3 replicas start simultaneously.

---

## Verify Load Balancing

```bash
# X-Served-By cycles through 1 → 2 → 3 → 1 ...
for i in $(seq 1 9); do
  curl -s http://localhost/api/health | python3 -c "import sys,json; print('Backend:', json.load(sys.stdin)['served_by'])"
done
```

---

## Verify Failover

```bash
# Stop one replica — LB automatically routes around it
docker compose stop backend2

for i in $(seq 1 6); do
  curl -s http://localhost/api/health | python3 -c "import sys,json; print('Backend:', json.load(sys.stdin)['served_by'])"
done
# Only "1" and "3" appear

docker compose start backend2   # restore
```

---

## Load Test (k6)

Prerequisite: the system must be running (`docker compose up --build`).

```bash
# Install k6 (macOS)
brew install k6

# Run the load test
k6 run load-test/k6-load-test.js

# Export raw metrics for later analysis
k6 run --out json=load-test/results.json load-test/k6-load-test.js
```

### What the test does

1. **Setup**: registers a dedicated test user, gets a JWT
2. **Ramp-up** (30 s): 0 → 50 virtual users
3. **Sustained load** (60 s): 50 VUs each hitting `GET /api/users/me` with Bearer JWT
4. **Ramp-down** (30 s): 50 → 0 VUs

### Sample results (2026-05-17, MacBook, Docker Desktop, 3 replicas)

```
scenarios: (100.00%) 1 scenario, 50 max VUs, 2m30s max duration
default: 50 looping VUs for 2m0s (gracefulStop: 30s)

✓ status 200
✓ served_by present
✓ body has served_by

checks_total.......: 126624   1055/s
checks_succeeded...: 100.00%  126624 out of 126624
checks_failed......: 0.00%    0 out of 126624

error_rate.............: 0.00%  0 out of 42208
http_req_duration......: avg=6.3ms  min=0.6ms  med=5.5ms  max=62.5ms  p(90)=11.2ms  p(95)=13.3ms  p(99)=23.1ms
http_req_failed........: 0.00%  0 out of 42208
http_reqs..............: 42208  352/s
vus....................: 50     min=1   max=50
```

**Throughput**: ~352 req/s across all 3 replicas (~117 req/s per instance), each request exercising the full JWT + `__Secure-Fgp` cookie OWASP verification path.

**Bottleneck**: The DB connection pool is the primary bottleneck at high concurrency. Each backend holds 10 connections max (`MaxConns = 10`). Under extreme load (>200 VUs) requests queue waiting for a free DB connection. Tuning options:
- Increase `MaxConns` (bounded by PG's `max_connections` setting, default 100)
- Add a connection pooler like PgBouncer between backends and DB
- Add read replicas for read-heavy endpoints

**Cost of OWASP hardening**: The fingerprint cookie check adds one SHA-256 hash + one constant-time string compare per request (~microseconds), invisible in the latency distribution.

---

## API Reference

| Method | Path | Auth | Consumer |
|--------|------|------|----------|
| GET    | `/api/health`                        | —   | LB-Demo, ops |
| POST   | `/api/auth/register`                 | —   | Frontend (Register page) |
| POST   | `/api/auth/login`                    | —   | Frontend (Login page) |
| POST   | `/api/auth/refresh`                  | `__Secure-Refresh` cookie | Frontend (silent refresh on 401) |
| POST   | `/api/auth/logout`                   | —   | Frontend (ForumHeader) |
| GET    | `/api/users/me`                      | JWT + Fgp | **k6 load test only** ¹ |
| GET    | `/api/users/{id}`                    | —   | _unused_ ² |
| GET    | `/api/categories`                    | —   | Frontend (Home) |
| GET    | `/api/categories/{id}`               | —   | Frontend (CategoryView) |
| GET    | `/api/categories/{id}/threads`       | —   | Frontend (CategoryView) |
| POST   | `/api/categories/{id}/threads`       | JWT + Fgp | Frontend (NewThread) |
| GET    | `/api/threads/{id}/posts`            | —   | Frontend (ThreadView) |
| POST   | `/api/threads/{id}/posts`            | JWT + Fgp | Frontend (ThreadView) |
| PUT    | `/api/posts/{id}`                    | JWT + Fgp (author only) | Frontend (ThreadView edit) |
| DELETE | `/api/posts/{id}`                    | JWT + Fgp (author only) | _unused_ ² |

"JWT + Fgp" endpoints require **both** an `Authorization: Bearer <JWT>` header **and** the `__Secure-Fgp` cookie issued at login (OWASP token-sidejacking defense — see _Security_ below). Every response includes `"served_by": "N"` in the JSON body and `X-Served-By: N` header.

¹ **`GET /api/users/me`** has no UI surface — it exists specifically as the k6 load-test target because it's the cheapest JWT-protected operation (no body, no DB write, single user lookup by `sub`-claim). This isolates the **auth pipeline** (JWT decode + fingerprint SHA-256 + constant-time compare + user lookup) as the thing being measured.

² **`GET /api/users/{id}`** and **`DELETE /api/posts/{id}`** are implemented in the backend but have no UI consumer yet. They are kept as building blocks for a future profile page and post-deletion flow respectively; the backend already enforces author-only authorization on the delete path.

---

## Security

Authentication follows the [OWASP JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html):

- **HS256 only**, algorithm whitelisted at parse time (`jwt.WithValidMethods`) — blocks `alg: none` and RSA-confusion attacks.
- **15-minute access tokens** with `iss`, `sub`, `iat`, `nbf`, `exp` claims; 30 s clock-skew leeway.
- **Token sidejacking defense**: every login sets a 50-byte random `__Secure-Fgp` cookie (`HttpOnly` + `Secure` + `SameSite=Strict`, `Path=/api`, `MaxAge=900`); SHA-256 of that value is stored in the JWT as the `userFingerprint` claim and constant-time-compared on every protected request. A stolen JWT alone is not enough to impersonate the user.
- **Refresh tokens** (7-day lifetime) live in an `HttpOnly` + `Secure` + `SameSite=Strict` `__Secure-Refresh` cookie scoped to `Path=/api/auth` (so they are never sent to regular API endpoints). Only the SHA-256 hash is stored in the `refresh_tokens` table — a DB dump yields no usable tokens.
- **Refresh-token rotation**: every successful `POST /api/auth/refresh` atomically marks the presented token as revoked (`UPDATE … RETURNING`) and issues a fresh refresh token + fresh fingerprint + fresh access token. A re-used (rotated) refresh token is rejected.
- **Argon2id** password hashing (default params, internal salt), minimum 12 characters on registration.
- **`POST /api/auth/logout`** revokes the refresh token in the DB and clears both `__Secure-Fgp` and `__Secure-Refresh` cookies, immediately ending the session even before the access token expires.
- **`JWT_SECRET`** must be ≥ 64 characters; the backend fails fast if a shorter value is set.
- **Frontend** stores the JWT in `sessionStorage` (cleared on tab close), serves a CSP + `X-Frame-Options: DENY` + `X-Content-Type-Options: nosniff` + `Referrer-Policy` from its Nginx container. The API client silently calls `/api/auth/refresh` on any 401 and retries the original request.

---

## Tech Stack

| Layer         | Technology                                              |
|---------------|---------------------------------------------------------|
| Backend       | Go 1.26, Chi v5.2.5, pgx v5.9.2, Argon2id, JWT (HS256) |
| Database      | PostgreSQL 18                                           |
| Load Balancer | Nginx 1.30 (round-robin, passive failover)              |
| Frontend      | React 19, TypeScript 6, Vite 8, Zustand, React Router 7 |
| Container     | Docker Compose v2                                       |
