docs: add index README and source README snapshots
This commit is contained in:
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Runic Gateway — Documentation
|
||||||
|
|
||||||
|
Central documentation for the Runic Gateway platform. The docs here were
|
||||||
|
extracted from the two code repositories (with full commit history preserved)
|
||||||
|
so they live in one place, independent of either codebase.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||||
|
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `website/`
|
||||||
|
| Doc | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||||
|
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||||
|
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||||
|
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
|
||||||
|
|
||||||
|
### `link/`
|
||||||
|
| Doc | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
|
||||||
|
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
|
||||||
|
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
|
||||||
|
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
|
||||||
|
| [PLAN.md](link/PLAN.md) | uo-link build plan |
|
||||||
|
| [RESEARCH.md](link/RESEARCH.md) | Research notes |
|
||||||
|
| [link-README.md](link/link-README.md) | Snapshot of the link repo's README |
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.
|
||||||
|
- `link/*` was extracted from `RunicGateway/link` via `git filter-repo`.
|
||||||
|
|
||||||
|
Commit history and authorship for each doc are preserved. The two source repos
|
||||||
|
retain a short pointer to this repo in their own READMEs; the authoritative copy
|
||||||
|
of each document now lives here.
|
||||||
94
link/link-README.md
Normal file
94
link/link-README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# uo-link
|
||||||
|
|
||||||
|
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
||||||
|
|
||||||
|
```
|
||||||
|
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||||
|
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||||||
|
```
|
||||||
|
|
||||||
|
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|------|------|
|
||||||
|
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
||||||
|
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||||
|
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
||||||
|
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
||||||
|
| `docs/INTEGRATION.md` | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
|
||||||
|
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. |
|
||||||
|
| `docs/RESEARCH.md` | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
||||||
|
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. |
|
||||||
|
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
||||||
|
|
||||||
|
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
||||||
|
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Phase | State |
|
||||||
|
|------:|-------|
|
||||||
|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||||
|
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
|
||||||
|
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
|
||||||
|
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
|
||||||
|
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
|
||||||
|
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
|
||||||
|
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** |
|
||||||
|
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** |
|
||||||
|
|
||||||
|
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
||||||
|
|
||||||
|
Cheat-detection signals are not a separate phase — they are folded into the streams above: `cheat.fastwalk`, `audit.set`, `audit.command`, and `vendor.sale` (buyer + owner for laundering detection).
|
||||||
|
|
||||||
|
## Phase 0 — what it fixes
|
||||||
|
|
||||||
|
`ScriptCompiler.Compile()` runs `dotnet build Scripts/Scripts.csproj -c Release`, prints the output, and **never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Because that build passed no `Platform`, MSBuild defaulted to `AnyCPU`, and `Scripts.csproj` gated both `OutputPath` and `DefineConstants` on `Configuration|Platform == Release|x64`. So:
|
||||||
|
|
||||||
|
- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
|
||||||
|
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
||||||
|
|
||||||
|
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
||||||
|
|
||||||
|
`Server.csproj` is deliberately left alone: nothing under `Server/` uses those symbols, and giving it `OutputPath=..\` would make the boot-time build try to overwrite the running `ServUO.exe`.
|
||||||
|
|
||||||
|
## The plugin (Phase 1)
|
||||||
|
|
||||||
|
`overlay/Scripts/Custom/Bridge/`:
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `BridgeConfig.cs` | Reads `Config/Bridge.cfg` in `Configure()`, before `World.Load`. |
|
||||||
|
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
|
||||||
|
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
|
||||||
|
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
|
||||||
|
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
|
||||||
|
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
|
||||||
|
| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
|
||||||
|
| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
|
||||||
|
| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
|
||||||
|
| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
|
||||||
|
|
||||||
|
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
|
||||||
|
|
||||||
|
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
||||||
|
|
||||||
|
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
|
||||||
466
website/website-README.md
Normal file
466
website/website-README.md
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
# 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](https://gitea.whitlocktech.com/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)](#shard-integration-uo-link).
|
||||||
|
|
||||||
|
The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, schema, security).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Tech stack](#tech-stack)
|
||||||
|
- [Project structure](#project-structure)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Setup & run](#setup--run)
|
||||||
|
- [Option A — Docker Compose (full stack)](#option-a--docker-compose-full-stack)
|
||||||
|
- [Option B — Local development (hot reload)](#option-b--local-development-hot-reload)
|
||||||
|
- [Option C — Production build without Docker](#option-c--production-build-without-docker)
|
||||||
|
- [First admin & site mode](#first-admin--site-mode)
|
||||||
|
- [Pages & routes](#pages--routes)
|
||||||
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
|
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
||||||
|
- [Environment variables](#environment-variables)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Logging](#logging)
|
||||||
|
- [Deployment behind Pangolin](#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 |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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](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](https://github.com/davibaltar/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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shard integration (uo-link)
|
||||||
|
|
||||||
|
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](https://gitea.whitlocktech.com/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 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-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`.
|
||||||
Reference in New Issue
Block a user