# 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](#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 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 ([RunicGateway/link](https://gitea.whitlocktech.com/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)](#shard-integration-uo-link).
- **Moderation appeals** — a player whose linked Discord identity was banned or muted (per the bot's `mod_actions` log) can open an appeal from the player portal; staff claim and resolve appeals from an admin queue, and approving a ban/mute appeal best-effort reverses it in Discord automatically. See [MODERATION_APPEALS.md](MODERATION_APPEALS.md).

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

---

## Contents

- [Architecture](#architecture)
- [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)

---

## 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.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up.

```mermaid
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/ &nbsp;— 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 |
| 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 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):

```bash
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):

```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 session cookie (name `rg_token`, configurable via `COOKIE_NAME`; 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
```

`swagger.js` post-processes the generator's output in two ways before writing it:

- **Trailing slashes are stripped from path keys.** swagger-autogen builds a path by
  string-concatenating the mount prefix with the route argument, so a capability router mounted at
  `/users` whose collection route is `router.get('/')` would document as `/api/v1/admin/users/` —
  a URL no client calls, while dropping the one they all do. Express is indifferent (non-strict
  routing treats the two as one route), but the published spec is a contract.
- **Path keys are sorted.** The generator emits them in router-traversal order, so moving a route
  between files rewrote most of this ~5k-line committed artifact even when the API was provably
  unchanged. Sorting keeps the diff proportional to the change. OpenAPI attaches no meaning to path
  order, and `scripts/routeManifest.js` already sorts for the same reason.

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](https://gitea.whitlocktech.com/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 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 by name. Their in-game **map location is only included for admin/moderator viewers** — for players and the public it is stripped from the payload entirely (server-enforced, not just hidden in the UI). 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 + Pangolin 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](#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) |
| _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`) |

---

## 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`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/.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 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`.
