Frontend for the uo-link integration, matching the existing site styling. - api/client.js: api.shard.* (status/feed/economy/idoc/char), the shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/ vendors). - lib/useShardFeed.js: EventSource hook over /public/shard/stream with a rolling buffer and a connected flag (browser never touches the sidecar WS). - routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply / link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses" lists, and a live event ticker — built from the shared panel/grid/format vocabulary. Registered at /site/shard under the maintenance gate and linked from the site header. - routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link code to link an account, then expand it to see characters and player vendors on demand (503 shows a retry banner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
UOMysticmoon Website
Public site, wiki, and protected admin panel for the UOMysticmoon private Ultima Online shard — a full-stack app in one repo:
- Backend — Node.js + Express REST API (layered
router → controller → model → db), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - Frontend — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- Deploy — Docker Compose (app + MariaDB) behind a Pangolin reverse proxy. Express serves the built SPA in production.
The design reference is BACKEND_DESIGN.md (API contract, schema, security).
Contents
- Tech stack
- Project structure
- Prerequisites
- Setup & run
- First admin & site mode
- Pages & routes
- API endpoints
- API documentation (Swagger)
- Environment variables
- Security
- Logging
- Deployment behind Pangolin
Tech stack
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, mariadb driver (parameterized SQL, no ORM) |
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (speakeasy + qrcode), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
Nodemailer via Gmail OAuth2 (configured in admin), with a mailto: fallback |
|
| API docs | OpenAPI 3.0 via swagger-autogen, served with swagger-ui-express at /api/docs |
| Deploy | Docker Compose, Pangolin reverse proxy |
Project structure
UOMSITE/
├─ server/ Express API
│ ├─ src/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger
│ ├─ db/ schema.sql + seed.js
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec)
│ └─ .env.example
├─ client/ React + Vite SPA
│ ├─ src/
│ │ ├─ routes/public/ Portal, Website, News, Screenshots, FiveOnFriday, Newsletter(+Issue), Status, About, Maintenance
│ │ ├─ routes/wiki/ Wiki landing + WikiArticle
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
│ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image
├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose)
└─ package.json workspace scripts
Prerequisites
- Node.js 20+ and npm (Node 22/24 are fine).
- Docker Desktop (for MariaDB, and for the full Compose deploy).
Setup & run
Option A — Docker Compose (full stack)
The simplest way to run everything. The image installs server deps, builds the React client, and Express serves it; MariaDB runs in its own container; tables + defaults + the first admin are created automatically on first boot.
cp .env.example .env
# Edit .env and set at least:
# DB_PASSWORD, DB_ROOT_PASSWORD (any strong values)
# JWT_SECRET (a long random string)
# ADMIN_USERNAME, ADMIN_PASSWORD (your first admin login)
docker compose up -d --build
- App: http://localhost:3000 (binds
0.0.0.0) - Health check:
GET http://localhost:3000/api/health→{ "status": "ok" } - Logs:
docker compose logs -f app(and./logs/app.logon the host) - Stop:
docker compose down(add-vto also wipe the database + uploads volumes)
Option B — Local development (hot reload)
Run the API and the Vite dev server separately. The Vite server proxies /api and /uploads
to the backend, so the SPA stays same-origin (cookies work).
1. Start a MariaDB the backend can reach (published on localhost:3306):
docker run -d --name uomm-db -p 3306:3306 -e MARIADB_DATABASE=uomysticmoon -e MARIADB_USER=uomm -e MARIADB_PASSWORD=devpass -e MARIADB_ROOT_PASSWORD=rootpass mariadb:11
2. Configure + start the backend (terminal 1):
cp server/.env.example server/.env
# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=uomm, DB_PASSWORD=devpass,
# JWT_SECRET=<anything>, ADMIN_USERNAME=admin, ADMIN_PASSWORD=<your password>
npm run install-server
npm run server # nodemon → http://localhost:3000
3. Start the frontend (terminal 2):
npm run install-client
npm run client # Vite → http://localhost:5173
Develop at http://localhost:5173 (hot reload). On Windows, the Vite proxy targets
127.0.0.1:3000 to avoid the IPv6-localhost pitfall.
Tip:
npm run install-allinstalls both server and client deps in one go.
Option C — Production build without Docker
Build the SPA and let Express serve it on a single port (still needs a MariaDB + server/.env):
npm run install-all
npm run build # → client/dist
npm start # node server → serves API + SPA at http://localhost:3000
First admin & site mode
- On first boot, if the
userstable is empty andADMIN_USERNAME/ADMIN_PASSWORDare set, the first admin is created automatically. You can also runnpm run seed. After it exists you may blank those env vars. - The site starts in
maintenancemode: public visitors see the polished "coming soon" page; the admin login and panel are always reachable. - Sign in at
/admin/login, then flip Maintenance → Live from the Dashboard. A logged-in admin can preview the live site even while it's in maintenance.
Pages & routes
Public (gated by site mode):
| Route | Page |
|---|---|
/ |
Portal landing (hero + destinations) |
/site |
Website index (section cards) |
/site/news |
News feed |
/site/screenshots |
Screenshot gallery |
/site/five-on-friday |
Five on Friday |
/site/newsletter · /site/newsletter/:id |
Newsletter list + issue |
/site/about · /site/status |
About · Shard status |
/wiki · /wiki/:slug |
Wiki landing + article (auto table-of-contents) |
Admin (cookie auth, noindex):
| Route | View |
|---|---|
/admin/login |
Sign in |
/admin |
Dashboard (mode toggle, stats, recent activity) |
/admin/posts |
Posts CRUD + publish + image upload |
/admin/wiki |
Wiki pages CRUD |
/admin/settings |
Site settings |
/admin/activity |
Activity log |
/admin/bot-activity |
Bot activity — banned IPs + recent scoring events, emergency unban (admin only) |
/admin/auth-providers |
Authentication — enable/configure SSO providers: built-in Google & Discord + custom OIDC/OAuth2 (admin only) |
/admin/users |
User management |
/admin/account |
Account security (self-service TOTP two-factor + linked SSO accounts) |
API endpoints
| Group | Base | Auth |
|---|---|---|
| Auth (web) | /api/v1/auth (login, login/totp, logout, me) |
cookie |
| Auth (mobile) | /api/v1/auth/mobile (login, refresh, logout) |
bearer (access + refresh tokens) |
| SSO | /api/v1/auth (providers — public discovery; sso/:provider/start, sso/:provider/link, sso/:provider/callback) |
redirect flow |
| Public | /api/v1/public (settings, status, posts/:category, posts/:category/:idOrSlug, wiki, wiki/:slug, contact) |
none |
| Admin | /api/v1/admin (dashboard, site-mode, posts, posts/upload, wiki, settings, activity, bot-activity, bot-activity/unban, auth/providers (CRUD), users, account, account/totp/*, account/identities) |
cookie (admin) |
Post categories (URL form): news, five-on-friday, newsletter, screenshots.
authMethod on a session ∈ local · totp · mobile · google · discord · oidc.
See BACKEND_DESIGN.md §4 for the full contract, or the interactive Swagger
docs below for a per-endpoint reference (parameters, request bodies, response codes).
API documentation (Swagger)
The full API is documented as an OpenAPI 3.0 spec and served with Swagger UI:
| URL | What |
|---|---|
http://localhost:3000/api/docs |
Interactive Swagger UI (try-it-out, auth) |
http://localhost:3000/api/docs.json |
Raw OpenAPI 3.0 spec (JSON) |
Every endpoint is tagged and grouped (Auth, Auth · Mobile, Auth · SSO, Public, and the Admin
groups) with its summary, parameters, request body, security requirement, and the response codes it
actually returns (400 validation, 401/403 auth, 404, 409 conflicts, 429 rate limits, …).
Authentication in the UI — click Authorize and provide either:
cookieAuth— theuomm_tokensession cookie (set automatically in the browser afterPOST /api/v1/auth/login), orbearerAuth— a mobile access token fromPOST /api/v1/auth/mobile/login(sent asAuthorization: Bearer <token>).
Regenerating the spec — the spec is generated from #swagger.* annotations next to each route
(server/src/router/**) plus the shared definitions in server/swagger/swagger.js
(swagger-autogen). The output
server/swagger/swagger-output.json is committed so the docs work with no build step. After adding
or changing a route, regenerate it:
cd server
npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables /api/docs (it does
not crash).
Environment variables
Copy .env.example (Compose) or server/.env.example (local) and fill in. .env is git-ignored.
| Var | Default | Notes |
|---|---|---|
NODE_ENV |
production |
|
PORT |
3000 |
server listens on 0.0.0.0:PORT |
UPLOAD_DIR |
<server>/uploads |
where post images are written (/app/uploads, volume-mounted, in Compose) |
DB_HOST / DB_PORT |
db / 3306 |
db in Compose; 127.0.0.1 for local dev |
DB_NAME / DB_USER / DB_PASSWORD |
uomysticmoon / uomm / — |
app database credentials |
DB_ROOT_PASSWORD |
— | MariaDB root (Compose only) |
JWT_SECRET |
— | required — long random string; signs session, mobile, and SSO-flow tokens |
JWT_EXPIRES_IN |
1d |
web session token + cookie lifetime |
COOKIE_SECURE |
auto |
auto = Secure only over HTTPS (works on LAN HTTP + Pangolin HTTPS) |
COOKIE_NAME |
uomm_token |
|
SECRET_ENC_KEY |
— | required in prod — key for AES-256-GCM encryption of stored OAuth client secrets. Dev falls back to a key derived from JWT_SECRET (with a warning) |
APP_BASE_URL |
— | public base URL, used to build the SSO OAuth redirect_uri (${APP_BASE_URL}/api/v1/auth/sso/:provider/callback). Set in prod to match what you register with Google/Discord; if unset it is derived from the request (fine for local dev) |
MOBILE_ACCESS_TTL |
15m |
mobile bearer access token lifetime (short-lived) |
MOBILE_REFRESH_TTL_DAYS |
30 |
mobile refresh token lifetime (long-lived, rotated on use) |
TRUST_PROXY |
1 |
reverse-proxy trust for correct req.ip / req.secure (rate limiting, backoff, bot-ban). Pin to the proxy hop's LAN IP in prod. A blanket true is rejected (coerced to 1) to block X-Forwarded-For spoofing |
DEBUG_TRUST_PROXY |
0 |
1 logs raw peer address + X-Forwarded-For + resolved req.ip per request (to verify/refresh the proxy IP). Noisy — leave off |
TOTP_ISSUER |
UOMysticmoon |
label shown in authenticator apps for optional per-user 2FA |
TOTP_CHALLENGE_TTL |
5m |
lifetime of the short-lived post-password "awaiting code" step |
ADMIN_USERNAME / ADMIN_PASSWORD |
— | first-admin bootstrap (first boot only) |
| — | configured in Admin → Settings → Email (Gmail OAuth2), not via env; recipient = contact_email setting |
|
CLIENT_ORIGIN |
http://localhost:5173 |
enables CORS in dev only |
LOG_LEVEL / FILE_LOG_LEVEL |
info / debug |
console / file verbosity |
LOG_TO_FILE / LOG_DIR / LOG_FILE |
true / <server>/logs / app.log |
log file (bind-mounted to ./logs in Docker) |
Security
Session & authorization
- All auth flows go through one session service (
server/src/auth/): controllers callsessionService.createSession(user, authMethod)and middleware callsvalidateSession(), so web cookies, mobile bearer tokens, and SSO all produce the same authenticated session model.utils/auth.jsremains a thin backward-compat facade. - JWT in an httpOnly,
SameSite=Laxcookie (Secureauto-detected), bcrypt password hashing. - Admin routes are re-validated against the database on every request, so a demoted or deleted user loses access immediately instead of keeping their old role until the token expires.
- Role-based authorization — admin-only endpoints (users, site mode, settings, auth providers)
are gated by a
requireRolecheck, so a lower-privilege editor can't reach them.
Mobile bearer auth
- Native clients use
/api/v1/auth/mobile/*: a short-lived access token (bearer JWT, validated by the same middleware as the cookie) plus a long-lived, server-stored, revocable refresh token that is rotated on every refresh (a replayed refresh token is single-use). Refresh tokens are stored hashed (never in the clear); logout revokes one or all. Mobile login reuses the same bot-scoring + backoff defenses as web, with single-request TOTP.
Single sign-on (OAuth2 / OIDC)
- Pluggable providers — built-in Google and Discord (endpoints fixed in code; admins supply
only client id/secret) plus fully-configurable custom OIDC/OAuth2 providers, managed from the
Authentication admin panel. Only
enabled+ fully-configured providers are shown to users. - Link-only by policy: an SSO login succeeds only if the external identity is already linked to an existing account (linked by the user from Account). External identities are never auto-provisioned — no one gains access without an account you created.
- The redirect flow is CSRF-protected with a signed, httpOnly, short-lived transaction cookie plus
PKCE; OAuth client secrets are encrypted at rest (AES-256-GCM) and never returned to any
client. SSO logins go through the same
sessionService, so login/activity logging, RBAC, and bot protection are identical to a local login.
Login hardening
- Optional per-user TOTP two-factor (opt-in, self-service on
/admin/account). When enabled, the password step issues only a short-lived, non-sessionstage:'totp'challenge; a session cookie is granted only after the second factor verifies. - Login throttling —
express-slow-down+ a hard rate cap + a separate per-IP exponential backoff, with generic error messages that don't reveal whether the username exists. - Honeypot field on the login form; submissions that fill it are treated as bots.
- Bot-scoring + automatic IP ban — weighted scoring of CMS-scanner paths and junk 404s (with a periodic sweep of stale entries) bans hostile scanners; failed logins and honeypot hits feed the score. Admins get visibility into this on the Bot Activity panel: currently banned IPs and a recent-events feed (in-memory, most-recent-first), plus a logged emergency unban for false positives — read + unban only, not a scoring-config surface.
Uploads & input
- Uploaded file extensions are derived from the validated mimetype, not the client-supplied filename (prevents a disguised-extension upload).
express-validatoron all writes; usernames are validated and uniqueness-checked on update.
Platform
helmet, admin routesnoindex+robots.txtdisallow,trust proxyfor correct client IPs behind Pangolin (seeTRUST_PROXY), first admin seeded from env (no hardcoded credentials),.envgit-ignored. Passwords and request bodies are never logged. Email sends through Gmail OAuth2 configured in the admin (refresh token stored AES-GCM-encrypted, never in env); the contact form falls back to amailto:link when unconfigured.
Logging
Every log line goes to both the console and a log file, timestamped and leveled
(error / warn / info / debug):
2026-06-26T18:55:01.123Z INFO [server] listening on http://0.0.0.0:3000 ...
2026-06-26T18:55:09.880Z INFO [http] 192.168.1.40 admin POST /api/v1/auth/login 200 12 ms - 48 bytes
2026-06-26T18:55:14.402Z WARN [auth] login failed {"username":"root","ip":"192.168.1.40"}
2026-06-26T18:55:20.110Z ERROR [error] GET /api/v1/public/wiki -> 500 ... {"stack":"..."}
Captured: startup config banner, schema/seed steps, HTTP access logs (real client IP via
trust proxy, the authenticated admin, method/URL/status/time/size), login success/failure,
rate-limit hits, site-mode changes, all errors with stack traces, and graceful shutdown. Console
verbosity is LOG_LEVEL; the file keeps the fuller FILE_LOG_LEVEL record. In Docker the file is
bind-mounted to ./logs/app.log and docker compose logs -f app shows the console stream.
Deployment behind Pangolin
docker compose up -d --build exposes the app container on 0.0.0.0:3000 (no 127.0.0.1
binding) so Pangolin can reach it. Point a Pangolin resource at app:3000. Because COOKIE_SECURE
defaults to auto, the admin login works both directly via the LAN IP over HTTP and through
Pangolin over HTTPS — no config change needed. MariaDB stays on the private Compose network
(no published port by default); data persists in the dbdata volume, uploads in uploads.