commit efbafe720370f2445c34f4e5386efe151869d0e4 Author: whitlocktech Date: Fri Jun 26 20:58:32 2026 -0500 Initial commit: UOMysticmoon backend (Express + MariaDB + JWT) - Layered API (router -> controller -> model -> db), serverlinkr pattern - Public / auth / admin route groups; posts, wiki, settings, users, activity models - JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS) - Site LIVE/MAINTENANCE mode with admin preview bypass - Dual file+console logging (info/warn/error/debug) + HTTP access logs - Docker Compose (app + MariaDB), schema.sql + seed, .env.example - Verified end-to-end against MariaDB (27/27 smoke checks) Co-Authored-By: Claude Opus 4.8 diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md new file mode 100644 index 0000000..4bf9c9a --- /dev/null +++ b/website/BACKEND_DESIGN.md @@ -0,0 +1,330 @@ +# 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 `/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 | +```