Files
docs/website/BACKEND_DESIGN.md
wtclaude 63bce88bd7 docs: document /auth/me self surface; mark PLAN §8.1 done
Counterpart to RunicGateway/website#76 (role-agnostic /auth/me/* self surface).

- BACKEND_DESIGN.md: add the /auth/me/account* rows to the /auth API contract and
  a note that the surface reuses account.controller behind requireAuth (any role),
  so a client manages its own account without touching /admin.
- android/PLAN.md: mark §8 item 1 (role-agnostic self-service) DONE and update the
  prerequisite-progress summary; version/health (item 4) and branding (item 6)
  remain open, push (item 3) is post-v1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:56:55 -05:00

19 KiB

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
col type notes
id INT PK AUTO_INCREMENT
token_hash CHAR(64) UNIQUE NOT NULL sha256 hex of the opaque token; plaintext never stored
user_id INT NOT NULL FK→users(id) ON DELETE CASCADE the account this reset targets
status ENUM('pending','used') DEFAULT 'pending' single-use (atomic markUsed)
requested_ip VARCHAR(64) NULL who asked (audit only)
expires_at DATETIME NOT NULL ~1h TTL, enforced in the model on top of this
created_at / used_at DATETIME

Same "store only the hash of an opaque token" pattern as user_invites / mobile_refresh_tokens. A DB read never yields a usable reset link. See §4 /auth/password/*.


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 / bearer current user (no hash) or 401 — client bootstraps auth state
POST /password/forgot — (rate-limited) {email} email a single-use, ~1h reset link to every active account on the address; always returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs account.password.reset.request.
GET /password/reset/:token validate a link → {username} for the form, else 404 (never distinguishes expired/used/never-existed)
POST /password/reset/:token — (rate-limited) {password} consume the single-use link, rotate the hash, and revoke all sessions (web cutoff + mobile refresh tokens). Does not sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs account.password.reset.complete.
GET /me/account cookie / bearer full self account (id, username, role, email, status, totp_enabled, has_password)
PATCH /me/account/username cookie / bearer (rate-limited) {username} change own username; re-issues the caller's session
PATCH /me/account/password cookie / bearer (rate-limited) {newPassword, currentPassword?} change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's
POST /me/account/totp/setup · …/enable · …/disable cookie / bearer {code} on enable/disable self 2FA enrollment (disable needs a valid current code, not a password)
GET /me/account/identities · DELETE …/:provider cookie / bearer list / unlink own SSO identities

Role-agnostic self-service (/auth/me/*). The canonical "me" surface for every authenticated role. It reuses the exact account.controller handlers as /player/account/* and /admin/account/* (no logic duplication) behind requireAuth only — any active account, never a specific role. This lets a client (the Android app) manage its own account through one surface without ever touching /admin (docs/android/PLAN.md §6.4). The older /player/account/* + /admin/account/* routes stay for web back-compat.

Password reset. Uses the same audited pattern as user_invites: an opaque 32-byte token whose sha256 hash only is stored in password_resets, single-use and short-lived (~1h). It also serves SSO-only accounts (null password_hash) as their "set an initial password" path. The reset link points at the web front end (/account/reset/:token); the Android app hands off here rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped by seed.js from env (see §6); further staff are created under /admin/users or via email invites.

/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=autosecure: 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) sends through Gmail over OAuth2 (SMTP XOAUTH2), configured in Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent flow (/admin/email/*) that captures a refresh token, stored AES-GCM-encrypted in the email_config singleton (never returned over the API). The OAuth client id/secret are reused from the google auth-providers row. Recipient is the contact_email site setting. If email is unconfigured/disabled, POST /public/contact returns {fallback:"mailto", email} so the client renders a mailto: link instead. Errors never leak credentials.


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=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
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