Add opt-in "Trust this device" so a browser/app skips the TOTP step (never the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout fallback, and admin trusted-device/MFA-reset management — backend, web UI, OpenAPI spec, and tests. - Schema: trusted_devices (sha256 token hash, looked up by unique index) and recovery_codes (bcrypt, single-use). Both additive/idempotent. - Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust httpOnly cookie (survives logout, revoked on untrust/password change/reset/ TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim. - Web + mobile login accept a trusted-device token / recovery code; login/totp gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an over-cap trust returns 409/trustLimitReached and the client prompts to revoke. - Self-service /auth/me/trusted-devices* + recovery-codes*; admin /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged. - Client: "Trust this device" + recovery-code login options, one-time recovery code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled revoke-to-continue cap modal, and admin per-user security controls. - OpenAPI regenerated; 33 new server tests (all suites green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runic Gateway Website
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
full-stack app in one repo. Branding is instance-configurable via BRAND_* (see
Branding); UOMysticmoon is the first instance.
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 reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
- Shard link — a live bridge to the in-game ServUO shard through the uo-link sidecar (RunicGateway/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), in the RunicGateway/docs repo — where all project documentation now lives.
Contents
- Architecture
- Tech stack
- Project structure
- Prerequisites
- Setup & run
- First admin & site mode
- Pages & routes
- API endpoints
- API documentation (Swagger)
- Shard integration (uo-link)
- Environment variables
- Security
- Logging
- Deployment behind a reverse proxy
Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend
(router → controller → model → db), which persists to MariaDB and bridges to the live
game world only through the uo-link sidecar. The shard itself is never internet-facing.
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ — Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
- One backend, layered. Every request flows
middleware → router → controller → model → db. Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. All three surfaces produce the same session via the session layer. - The shard is never reachable. The ServUO shard dials out over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(
uoLinkClient.js) never throws, so the site degrades gracefully when the shard is down. - Sensitive events stay private. Ingested game events fan out to browsers over two SSE channels — a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
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, any reverse proxy (Pangolin, Nginx, Caddy, Traefik, …) |
Project structure
website/
├─ 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.logon the host) - Stop:
docker compose down(add-vto 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 rg-db -p 3306:3306 -e MARIADB_DATABASE=runic_gateway -e MARIADB_USER=runic -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=runic, 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) |
| 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 session cookie (namerg_token, configurable viaCOOKIE_NAME; 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).
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: RunicGateway/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 (RunicGateway/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 carriesAuthorization: Bearer <token>and anX-UOLink-Versionheader (a protocol mismatch fails fast with409instead 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), staffaudit.*/cheat.*,link.request, andserver.hello/shutdown. A single dispatcher (utils/shardIngest.js) routes each event: state-changing kinds updateshard_online/shard_economy/shard_houses; notable kinds are appended to an append-onlyshard_eventslog; high-frequency kinds (vitals, supply ticks) only update state and are not logged. A changed boot id onserver.hellois 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/confirmand/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 a503/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:
- In game, the player runs
[linkand receives a one-time code. - On the website (Player portal, or Admin → Account for staff) they enter the code.
- 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 inshard_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 |
runic_gateway / runic / — |
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 + proxy HTTPS) |
COOKIE_NAME |
rg_token |
changing it on a live instance invalidates existing sessions |
BRAND_* |
Runic Gateway | instance branding (name, tagline, colors, logo/hero/favicon) — see Branding |
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 |
BRAND_NAME |
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) |
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) |
Branding
Instance identity is data, not code — set via BRAND_* env vars, so one prebuilt
image can run as any shard. With none set, everything renders as Runic Gateway.
| Var | What |
|---|---|
BRAND_NAME / BRAND_SHORT_NAME |
display name (full / short-in-prose) |
BRAND_TAGLINE / BRAND_DESCRIPTION |
tagline + meta/OG description |
BRAND_CONTACT_EMAIL / BRAND_URL |
contact + canonical URL (for OG/absolute links) |
BRAND_ACCENT_COLOR |
theme --accent (web) + Discord embed color |
BRAND_LOGO / BRAND_HERO / BRAND_FAVICON |
image paths under the /brand mount, or absolute URLs |
How it flows: text/colors reach the SPA at runtime through the public settings
API (SiteContext), so no rebuild is needed; the server templates index.html
<title>/meta/OG/favicon at boot; emails, TOTP issuer, and the Discord bot read
BRAND_* directly. The admin-editable site title and contact email
settings override BRAND_NAME / BRAND_CONTACT_EMAIL when set. Image assets are
delivered from the ./brand bind-mount (see brand/README.md).
UOMysticmoon is the first instance — .env.uomysticmoon.example
holds the exact BRAND_* + infra (DB_NAME/DB_USER/COOKIE_NAME) pinning to
run this repo as UOMysticmoon.
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 a reverse proxy (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 a reverse proxy
docker compose up -d --build exposes the app container on 0.0.0.0:3000 (no 127.0.0.1
binding) so a reverse proxy — Pangolin, Nginx, Caddy, Traefik, etc. — can reach it. Point the
proxy at app:3000 (or the host's :3000 if the proxy runs outside Compose) and terminate TLS
there. Because COOKIE_SECURE defaults to auto, the admin login works both directly via the
LAN IP over HTTP and through the proxy 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.
Set TRUST_PROXY so Express reads the real client IP from the proxy's X-Forwarded-For header
(see Environment variables) — required for rate limiting, bot scoring,
and correct logging. Forward the standard X-Forwarded-For and X-Forwarded-Proto headers from
your proxy.
Minimal proxy examples:
# Nginx
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Caddy — Caddyfile (automatic HTTPS; forwards X-Forwarded-* by default)
your.domain {
reverse_proxy app:3000
}
Pangolin: create a resource targeting app:3000; it forwards the required headers and
terminates HTTPS out of the box, so no extra configuration is needed.
License
Runic Gateway is free software, licensed under the GNU General Public License v3.0 or later — see LICENSE.md.
Copyright (C) 2026 Runic Gateway
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version. It is distributed WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
Contributions are welcome — please read CONTRIBUTING.md (note the AI-usage disclosure requirement) and our Code of Conduct. Report vulnerabilities privately per SECURITY.md.