# UOMysticmoon Website — Backend Design

> Phase 1 of 3: **backend design** → Claude Design (frontend mockup) → coding.
> This document is the contract the later phases build against.

Public contact email: **UOMysticmoon@gmail.com**

---

## 1. Stack & top-level decisions

| Concern | Decision | Rationale |
|---|---|---|
| Runtime | Node.js + Express | serverlinkr pattern |
| Database | MariaDB (own container) | spec; `mariadb` pool, parameterized SQL, no ORM (keeps the lightweight `model`/`db` split from serverlinkr) |
| Auth | JWT in an **httpOnly cookie** | spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), `SameSite=Strict` covers CSRF for a same-origin admin panel |
| Frontend | React + Vite, same repo, served by Express in prod | spec |
| Hashing | bcrypt (`bcryptjs`) | spec; matches serverlinkr |
| Deploy | Docker Compose (app + db) behind Pangolin | spec |

**Adapting serverlinkr → this project**
- `*.mongo.js` (mongoose) → `*.db.js` (MariaDB queries), exactly as the spec names them.
- Drop the session/passport hybrid (`express-session`, `passport`, `passport-local`, `connect-mongo`). Pure stateless JWT instead — simpler and matches "JWT auth".
- Routes grouped by **access level** (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by **entity**.

---

## 2. Folder structure

Skeleton from the spec, with a small number of justified additions marked **(+)**.

```
server/
  .env.example
  package.json
  db/
    schema.sql                  (+) DDL, also auto-run by the MariaDB container
    seed.js                     (+) seed wiki pages, default settings, first admin
  src/
    server.js                   bootstrap: ensure schema, then listen on 0.0.0.0
    app.js                      express app + middleware wiring
    router/
      api.router.js             mounts /v1
      v1/
        v1.router.js            mounts /auth /public /admin
        auth/    auth.routes.js    + auth.controller.js
        public/  public.routes.js  + public.controller.js
        admin/   admin.routes.js   + admin.controller.js
    model/
      users/     users.model.js    + users.db.js
      posts/     posts.model.js     + posts.db.js     (news/five-on-friday/newsletter/screenshots)
      wiki/      wiki.model.js      + wiki.db.js
      settings/  settings.model.js  + settings.db.js
      activity/  activity.model.js  + activity.db.js  (+) admin activity log
    middleware/                 (+)
      siteMode.js               LIVE/MAINTENANCE gate for public content
      noindex.js                X-Robots-Tag: noindex,nofollow on admin
      rateLimit.js              login limiter
      validate.js               express-validator error handler
    utils/
      auth.js                   JWT sign/verify, isLoggedIn middleware
      db.js                     MariaDB pool + ensureSchema()
      mailer.js                 (+) nodemailer; mailto fallback if SMTP unset
client/                         built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore
```

**Why the additions:** the spec's feature list requires an activity log, a maintenance-mode
gate, login rate limiting, admin `noindex`, and SMTP email — none fit cleanly in the four
listed models/two utils. They're isolated in `middleware/` + one `activity` model +
`utils/mailer.js`, and the spec explicitly says the layout is "expandable."

---

## 3. Database schema (MariaDB)

`utf8mb4` throughout. Created idempotently on boot (`ensureSchema()`) **and** shipped as
`db/schema.sql` for the container's `/docker-entrypoint-initdb.d`.

### users
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| username | VARCHAR(32) UNIQUE NOT NULL | |
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| last_login_at | DATETIME NULL | shown in user management |

### posts  — one table, four categories
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| category | ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(220) NULL | optional clean URL |
| excerpt | VARCHAR(400) NULL | list teaser |
| body | MEDIUMTEXT NULL | markdown/HTML; main text for news/5oF/newsletter |
| image_url | VARCHAR(500) NULL | required for `screenshot`, optional hero elsewhere |
| published | TINYINT(1) NOT NULL DEFAULT 0 | publish/unpublish toggle |
| author_id | INT NULL FK→users(id) | ON DELETE SET NULL |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
| updated_at | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| published_at | DATETIME NULL | set when first published; list order |

Index: `(category, published, published_at DESC)`.

### wiki_pages
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| slug | VARCHAR(120) UNIQUE NOT NULL | e.g. `new-player-guide` |
| title | VARCHAR(200) NOT NULL | |
| body | MEDIUMTEXT NULL | markdown/HTML |
| updated_by | INT NULL FK→users(id) | |
| created_at / updated_at | DATETIME | |

Seeded with the 8 spec categories: `new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules`.

### settings  — key/value, expandable
| col | type | notes |
|---|---|---|
| `key` | VARCHAR(64) PK | |
| value | TEXT NULL | |
| updated_by | INT NULL FK→users(id) | |
| updated_at | DATETIME ON UPDATE CURRENT_TIMESTAMP | |

Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.

### activity_log  — append-only
| col | type | notes |
|---|---|---|
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NULL FK→users(id) | |
| action | VARCHAR(64) NOT NULL | e.g. `auth.login`, `site_mode.change`, `post.create` |
| detail | TEXT NULL | JSON string of what changed |
| ip | VARCHAR(45) NULL | from `req.ip` (needs `trust proxy`) |
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |

---

## 4. API contract

Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
accepts `Authorization: Bearer` for API testing).

### /auth  (auth.routes.js → auth.controller.js)
| Method | Path | Auth | Body | Purpose |
|---|---|---|---|---|
| POST | `/login` | — (rate-limited) | `{username,password}` | verify, set cookie, log `auth.login`, update `last_login_at` |
| POST | `/logout` | cookie | — | clear cookie |
| GET | `/me` | cookie | — | current user (no hash) or 401 — client bootstraps auth state |

No public `register`. First admin is bootstrapped by `seed.js` from env (see §6). Further
admins are created under `/admin/users`.

### /public  (public.routes.js → public.controller.js) — all GET, no auth
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys only (mode, maintenance_message, status_message, homepage_teaser, contact_email, site_title) |
| GET | `/status` | status message + current mode |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
| GET | `/posts/:category/:idOrSlug` | single published post |
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |

Public content GETs pass through the **siteMode** gate (§5).

### /admin  (admin.routes.js → admin.controller.js) — all behind `isLoggedIn` + `noindex`
| Method | Path | Purpose |
|---|---|---|
| GET | `/dashboard` | current mode, last change time + who, content counts, recent activity |
| PUT | `/site-mode` | `{mode}` → update settings, stamp who/when, log `site_mode.change` |
| GET | `/posts?category=` | all posts incl. unpublished |
| POST | `/posts` | create |
| GET | `/posts/:id` | one |
| PUT | `/posts/:id` | edit |
| DELETE | `/posts/:id` | delete |
| PATCH | `/posts/:id/publish` | `{published}` toggle (sets `published_at`) |
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |

Every admin write logs to `activity_log`.

---

## 5. Site mode (LIVE / MAINTENANCE)

State in `settings.site_mode` (`live`|`maintenance`), default **maintenance**.

`middleware/siteMode.js`, applied only to **public content** routes:
- `live` → pass through.
- `maintenance` → respond **503** with `{mode:"maintenance", message}` **unless** the request
  carries a valid admin cookie (admin preview). This hides content server-side, not just in
  the UI.

Always reachable regardless of mode: static assets / SPA shell, `/api/v1/auth/*`, all
`/api/v1/admin/*`. So admin login + panel + the maintenance "coming soon" page always load.

**Client behavior (Phase 3):** reads `GET /public/settings`; if `maintenance` and not an
admin previewing, render the polished dark coming-soon page (message + contact email).
Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.

Dashboard reads `site_mode` + `site_mode_changed_at`/`_by` for "current mode + last change +
who"; `activity_log` provides the history feed.

---

## 6. Auth & security

- **JWT** signed with `JWT_SECRET`, `expiresIn=JWT_EXPIRES_IN` (default `1d`); payload `{id,username,role}`.
- **Cookie**: `httpOnly`, `sameSite=Lax`, `path=/`, and **`secure` decided per-request** (`COOKIE_SECURE=auto` → `secure: req.secure`). This is the key to dual access: the cookie is `Secure` when reached through Pangolin (HTTPS, `X-Forwarded-Proto: https`) but **not** `Secure` when reached directly over the LAN IP on plain HTTP — so login works in both. `COOKIE_SECURE=true|false` can force it. Requires `trust proxy` (below). `localhost:5173` (Vite) and `localhost:3000` are same-site, so the cookie flows in dev too.
- **bcrypt** hashing (cost 10+); plaintext passwords never stored, logged, or returned.
- **Rate limiting** (`express-rate-limit`) on `/auth/login` and `/public/contact`.
- **Validation** (`express-validator`) on all writes; centralized error handler.
- **helmet** with a CSP suited to the SPA (self + inline styles as needed; image sources for uploads/hero).
- **Admin not indexed**: `X-Robots-Tag: noindex, nofollow` on `/api/v1/admin` and the admin SPA routes; `robots.txt` disallows `/admin`.
- **No directory browsing** (express.static doesn't list; no `serve-index`).
- **No hardcoded credentials**: first admin via `seed.js` reading `ADMIN_USERNAME`/`ADMIN_PASSWORD` from env (created only if no users exist); `.env` git-ignored, `.env.example` committed.
- **`app.set('trust proxy', 1)`** so secure cookies, `req.ip`, and rate-limiting work behind Pangolin.
- **CORS**: same-origin in prod (SPA served by Express). Dev only: allow `CLIENT_ORIGIN` (Vite, `http://localhost:5173`) with `credentials:true`.

---

## 7. Email

`utils/mailer.js` (nodemailer) configured from `SMTP_HOST/PORT/USER/PASS`, sending to
`CONTACT_TO` (default UOMysticmoon@gmail.com). No Gmail password in code — env only.
If SMTP is unconfigured, `POST /public/contact` returns `{fallback:"mailto", email}` so the
client renders a `mailto:` link instead. Site mode changes / errors never leak SMTP creds.

---

## 7.5 Logging & observability

`utils/logger.js` — a small dependency-free logger with **two transports, console + file**,
and four levels (`error`/`warn`/`info`/`debug`). Each line is timestamped and tagged by
subsystem (`[server]`, `[http]`, `[db]`, `[auth]`, `[admin]`, `[ratelimit]`, …).

- **Console**: color on a TTY, plain in Docker; verbosity = `LOG_LEVEL` (default `info`).
- **File**: plain text appended to `LOG_DIR/LOG_FILE` (default `<server>/logs/app.log`,
  `/app/logs/app.log` in Docker, bind-mounted to `./logs`); verbosity = `FILE_LOG_LEVEL`
  (default `debug`, so the file keeps a complete record while the console stays readable).
  Toggle with `LOG_TO_FILE`. The stream is flushed on graceful shutdown.
- **HTTP access logs** via morgan piped into the logger: real client IP (`trust proxy`),
  authenticated admin username, method, URL, status, response time, size.
- **Captured events**: startup config banner, schema/seed steps, login success/failure,
  rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with
  stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never
  logged. `unhandledRejection`/`uncaughtException` are caught and logged.

## 8. Deployment

**docker-compose.yml** — two services on a private network:
- `db`: `mariadb:11`, env `MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD`, volume
  `dbdata:/var/lib/mysql`, mounts `schema.sql` into `/docker-entrypoint-initdb.d`, healthcheck.
- `app`: builds the Dockerfile (installs client+server, builds Vite, serves via Express),
  `env_file: .env`, `DB_HOST=db`, `depends_on: db (healthy)`, volume `uploads:/app/uploads`,
  `ports: "3000:3000"` — **binds 0.0.0.0** (no `127.0.0.1:` prefix) so Pangolin reaches it.
- Volumes: `dbdata`, `uploads`.

Express listens on `0.0.0.0:${PORT||3000}`. Pangolin terminates TLS and proxies to `app`.

**.env.example** (committed; real `.env` ignored):
```
NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
CONTACT_TO=UOMysticmoon@gmail.com
CLIENT_ORIGIN=http://localhost:5173
```

`.gitignore`: `node_modules/`, `.env`, `_reference/`, `client/dist/`, `uploads/`.

---

## 9. Dependencies (server)

`express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser,
express-rate-limit, express-validator, multer, nodemailer` · dev: `nodemon`.
Removed vs serverlinkr: `mongoose, mongodb, connect-mongo, express-session, passport,
passport-local`.

---

## 10. Spec coverage

| Spec requirement | Covered by |
|---|---|
| Public pages (`/`, `/site/*`, `/wiki/*`) | `/public/*` API + Phase-3 SPA routes; content from `posts`/`wiki`/`settings` |
| News / 5-on-Friday / Newsletter / Screenshots | `posts` table, `category` column; admin CRUD + publish |
| Wiki 8 categories, editable later | `wiki_pages` seeded with 8 slugs; admin CRUD |
| Status page | `settings.status_message` + mode via `/public/status` |
| Admin dashboard (mode, last change, who) | `/admin/dashboard` + settings stamps + activity log |
| Site mode toggle | `PUT /admin/site-mode` + `siteMode` middleware |
| Admin activity log | `activity_log` + `/admin/activity` |
| Admin user management | `/admin/users` CRUD |
| Site settings editing | `/admin/settings` |
| JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env | §6 |
| Maintenance page, admin always in, static always loads, admin preview | §5 |
| SMTP via env, mailto fallback | §7 |
| Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind | §8 |
| Design tokens / hero | reused from existing `assets/css/mysticmoon.css` + hero PNG in Phase 2/3 |
| Expandable | key/value settings, role enum, modular routers/models |
```
