Claude 5b6b63e1bc
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m43s
PR Checks / client-build (pull_request) Successful in 9m39s
PR Checks / bot-install (pull_request) Successful in 9m40s
fix(public): always show real hero; drop nav from landing page
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.

Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 22:57:49 -05:00
2026-07-06 01:17:16 -05:00

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.
  • Shard link — a live bridge to the in-game ServUO shard through the uo-link sidecar (UOM/link): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See Shard integration (uo-link).

The design reference is BACKEND_DESIGN.md (API contract, schema, security).


Contents


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
Email 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)

docker-compose.yml is production-shaped: it pulls the prebuilt app and bot images from the Gitea container registry (published by .gitea/workflows/build-images.yml on every merge to main) — it never builds. Each image already bundles the server deps and the built React client, which Express serves. 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 pull && docker compose up -d          # IMAGE_TAG defaults to `latest`
# pin a specific build (reproducible deploy / rollback):
IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
  • 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.log on the host)
  • Stop: docker compose down (add -v to also wipe the database + uploads volumes)

Build the images locally instead of pulling (offline, or to test an unmerged change) — overlay the dev file, which adds build: back:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

Keeping build: out of the base file means a production host can only ever pull — it can never accidentally build.

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-all installs 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 users table is empty and ADMIN_USERNAME / ADMIN_PASSWORD are set, the first admin is created automatically. You can also run npm run seed. After it exists you may blank those env vars.
  • The site starts in maintenance mode: 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)
Public · Shard /api/v1/public/shard (status, feed, economy, online, idoc, stream) none
Player · Shard /api/v1/player/shard (link, accounts, roster/:account, vendors/:account, char/:serial, sales) cookie/bearer (player)
Admin · Shard /api/v1/admin/shard (self linking, same as player) · /api/v1/admin/uo-link (config, towncrier, stream) cookie (staff / 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 — the uomm_token session cookie (set automatically in the browser after POST /api/v1/auth/login), or
  • bearerAuth — a mobile access token from POST /api/v1/auth/mobile/login (sent as Authorization: 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).


The site is wired to the live in-game world through uo-link, a standalone sidecar service that runs next to the ServUO shard. Its source lives in a separate repo: UOM/link. uo-link speaks the shard's internals and exposes a small, authenticated HTTP + WebSocket API; this website is a client of it. The shard itself is never exposed to the internet — only the sidecar is, and only the website's backend talks to it.

How it works

ServUO shard  ──▶  uo-link sidecar (UOM/link)  ──▶  website backend  ──▶  browser
                   REST + WebSocket, bearer-auth      ingest + REST         same-origin JSON/SSE
  • Connection is admin-managed, not env. The sidecar's base URL, WebSocket URL, shared-secret token, and protocol version are stored in the database (uoLinkConfig), edited from the Admin → Shard panel. The token is encrypted at rest (AES-256-GCM) and is write-only in the API — it is never returned to any client and never sent to the browser. Every call the backend makes carries Authorization: Bearer <token> and an X-UOLink-Version header (a protocol mismatch fails fast with 409 instead of being mis-parsed).
  • Live ingest (WebSocket). When enabled, the backend opens an outbound WebSocket to the sidecar and receives a stream of game events — mob.login/logout, char.vitals, economy.supply, vendor.sale, player.death/murdered, house.decay (IDOC), staff audit.*/cheat.*, link.request, and server.hello/shutdown. A single dispatcher (utils/shardIngest.js) routes each event: state-changing kinds update shard_online / shard_economy / shard_houses; notable kinds are appended to an append-only shard_events log; high-frequency kinds (vitals, supply ticks) only update state and are not logged. A changed boot id on server.hello is detected as a restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via the sidecar's /history.
  • Live round-trips (REST). For point-in-time reads the backend calls the sidecar directly — /char/serial/:serial, /roster/:account, /vendors/:account, /economy, /history — plus commands /link/confirm and /towncrier. The REST client (utils/uoLinkClient.js) never throws: every call returns { ok, data, status }, so a shard that is down or mid-restart degrades to a 503/retry banner instead of a 500.
  • Fan-out to the browser. Ingested events are pushed to browsers over Server-Sent Events. Two channels exist: a public stream carrying only a safe allowlist of kinds, and an admin-only stream that also includes sensitive kinds (staff audit, cheat detection, login attempts, IPs). Sensitive kinds can never leak onto the public channel.

Account linking

A player (or staff member) proves ownership of a game account without sharing any game credentials:

  1. In game, the player runs [link and receives a one-time code.
  2. On the website (Player portal, or Admin → Account for staff) they enter the code.
  3. The backend confirms the code with the sidecar (POST /link/confirm), which permanently tags the game account with the website user id, and mirrors the link locally in shard_account_links.

That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints are ownership-checked so a user only sees accounts they linked. Admins may view any character; players and editor/moderator staff are limited to their own linked accounts.

What each audience sees

Surface Endpoints Who Data
Public /api/v1/public/shard/* (status, feed, economy, online, idoc, stream) anyone Shard up/down, gold-supply series, IDOC houses, a curated live feed, and "Staff online" — only players whose account is linked to a staff user (admin/editor/moderator), shown with name + map location. Linked players are never listed publicly; no vitals or account are exposed.
Player /api/v1/player/shard/* (link, accounts, roster/:account, vendors/:account, char/:serial, sales) logged-in player Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales.
Admin /api/v1/admin/shard/* (self-linking, same as player) · /api/v1/admin/uo-link/* (config, towncrier, stream) staff / admin Staff link their own accounts like players; admins additionally read any character's data, edit the sidecar connection config, publish/remove town-crier messages, and subscribe to the full event stream (incl. audit/cheat).

The sidecar URL and token are set once in Admin → Shard; if uo-link is not configured (or the shard is offline), every shard surface degrades gracefully — the public page still renders, showing the shard as offline.


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)
Email 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)
ANNOUNCE_POLL_MS 15000 how often the news-announcement dispatcher sweeps announce_jobs for due/retry legs (town crier + Discord)
TOWNCRIER_DURATION_SEC 3600 how long a news post's in-game town-crier message stays up (≤ 86400)

Security

Session & authorization

  • All auth flows go through one session service (server/src/auth/): controllers call sessionService.createSession(user, authMethod) and middleware calls validateSession(), so web cookies, mobile bearer tokens, and SSO all produce the same authenticated session model. utils/auth.js remains a thin backward-compat facade.
  • JWT in an httpOnly, SameSite=Lax cookie (Secure auto-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 requireRole check, 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-session stage:'totp' challenge; a session cookie is granted only after the second factor verifies.
  • Login throttlingexpress-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-validator on all writes; usernames are validated and uniqueness-checked on update.

Platform

  • helmet, admin routes noindex + robots.txt disallow, trust proxy for correct client IPs behind Pangolin (see TRUST_PROXY), first admin seeded from env (no hardcoded credentials), .env git-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 a mailto: 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.

Description
No description provided
Readme 9.8 MiB
Languages
JavaScript 98.7%
CSS 0.9%
C# 0.3%