diff --git a/.env.example b/.env.example
index 9ba8c93..c932e62 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,7 @@
-# ─── UOMysticmoon — root environment (used by docker-compose) ───
+# ─── Runic Gateway — root environment (used by docker-compose) ───
# Copy to .env and fill in. NEVER commit the real .env.
+# To run this as an existing branded instance (e.g. UOMysticmoon), see
+# .env.uomysticmoon.example for the exact BRAND_*/DB pinning to copy in.
# Container image tag pulled by docker-compose (app + bot). Published by the
# Gitea Actions workflow on every merge to main as `latest` and `sha-<7>`.
@@ -23,12 +25,32 @@ LOG_TO_FILE=true # set false for console-only
LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs)
LOG_FILE=app.log
+# ─── Branding (BRAND_*) ───────────────────────────────────────────────────
+# Instance identity. Defaults render as "Runic Gateway"; set these to rebrand
+# without a rebuild. Text + colors reach the SPA through the settings API at
+# runtime; the server templates index.html
/meta/OG/favicon at boot. The
+# admin-editable "site title" and "contact email" settings, if set, override
+# BRAND_NAME / BRAND_CONTACT_EMAIL.
+BRAND_NAME=Runic Gateway
+BRAND_SHORT_NAME=Runic Gateway
+BRAND_TAGLINE=an independent private Ultima Online shard
+BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
+BRAND_CONTACT_EMAIL=
+BRAND_URL=
+# Accent color — drives the web theme's --accent and the Discord embed color.
+BRAND_ACCENT_COLOR=#7f99bd
+# Image assets: paths under the /brand mount (see docker-compose.yml) or absolute
+# URLs. Blank = built-in defaults (hero falls back to a neutral built-in image).
+BRAND_LOGO=
+BRAND_HERO=
+BRAND_FAVICON=
+
# Database (the values here are shared by the `db`, `app`, and `bot` containers —
# the bot only ever touches its own tables: guild_config, mod_actions, warnings)
DB_HOST=db
DB_PORT=3306
-DB_NAME=uomysticmoon
-DB_USER=uomm
+DB_NAME=runic_gateway
+DB_USER=runic
DB_PASSWORD=change-me-db-password
DB_ROOT_PASSWORD=change-me-root-password
@@ -38,7 +60,8 @@ JWT_EXPIRES_IN=1d
# auto = Secure cookie only when the request arrives over HTTPS (Pangolin).
# Leave as auto so login works both via the LAN IP (HTTP) and the proxy (HTTPS).
COOKIE_SECURE=auto
-COOKIE_NAME=uomm_token
+# Changing this on a live instance invalidates existing sessions (users re-login).
+COOKIE_NAME=rg_token
# Reverse-proxy trust (req.ip / req.secure for rate limiting, backoff, bot-ban).
# Path: client -> Pangolin -> newt agent "ptero" (separate VM) -> app. Pin this
@@ -51,8 +74,8 @@ TRUST_PROXY=1
# request (to verify/refresh ptero's IP without redeploying). Noisy; keep off.
DEBUG_TRUST_PROXY=0
-# Optional TOTP two-factor (opt-in per user).
-TOTP_ISSUER=UOMysticmoon
+# Optional TOTP two-factor (opt-in per user). Defaults to BRAND_NAME when unset.
+# TOTP_ISSUER=Runic Gateway
TOTP_CHALLENGE_TTL=5m
# First admin bootstrap — created only if no users exist yet.
diff --git a/.env.uomysticmoon.example b/.env.uomysticmoon.example
new file mode 100644
index 0000000..e1a5d58
--- /dev/null
+++ b/.env.uomysticmoon.example
@@ -0,0 +1,30 @@
+# ─── UOMysticmoon instance — BRAND_* / identity overrides ───
+#
+# Runic Gateway's first "tenant". Copy these into the deploy .env (on top of
+# .env.example) to run RunicGateway/website as UOMysticmoon. This is the proof
+# that branding is data, not code: the same image renders as UOMysticmoon purely
+# from these vars.
+#
+# Only the values that differ from the Runic Gateway defaults are shown.
+
+# Identity
+BRAND_NAME=UOMysticmoon
+BRAND_SHORT_NAME=Mysticmoon
+BRAND_TAGLINE=an independent private Ultima Online shard
+BRAND_DESCRIPTION=UOMysticmoon — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
+BRAND_CONTACT_EMAIL=UOMysticmoon@gmail.com
+# BRAND_URL=https://
+
+# Visual — the existing UOM accent + hero image (baked into the image already).
+BRAND_ACCENT_COLOR=#7f99bd
+BRAND_HERO=/assets/img/uomysticmoon-main-hero.png
+
+# TOTP label (defaults to BRAND_NAME, so optional — shown for clarity).
+TOTP_ISSUER=UOMysticmoon
+
+# ── Infrastructure identifiers — PIN to the existing production values so the
+# ── app keeps talking to the same database and existing sessions stay valid.
+# ── (These are NOT branding; they must match what production already uses.)
+DB_NAME=uomysticmoon
+DB_USER=uomm
+COOKIE_NAME=uomm_token
diff --git a/README.md b/README.md
index e794493..89be6f0 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,10 @@
-# UOMysticmoon Website
+# Runic Gateway Website
-Public site, wiki, and protected admin panel for the **UOMysticmoon** private Ultima Online
-shard — a full-stack app in one repo:
+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).
@@ -133,14 +136,14 @@ 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
+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=uomm, DB_PASSWORD=devpass,
+# Set DB_HOST=127.0.0.1, DB_PORT=3306, DB_USER=runic, DB_PASSWORD=devpass,
# JWT_SECRET=, ADMIN_USERNAME=admin, ADMIN_PASSWORD=
npm run install-server
npm run server # nodemon → http://localhost:3000
@@ -249,7 +252,7 @@ actually returns (`400` validation, `401`/`403` auth, `404`, `409` conflicts, `4
**Authentication in the UI** — click **Authorize** and provide either:
-- `cookieAuth` — the `uomm_token` session cookie (set automatically in the browser after
+- `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 `).
@@ -348,19 +351,20 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `/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_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` | `uomm_token` | |
+| `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` | `UOMysticmoon` | label shown in authenticator apps for optional per-user 2FA |
+| `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 |
@@ -372,6 +376,32 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
---
+## 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`
+``/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`](.env.uomysticmoon.example)
+holds the exact `BRAND_*` + infra (`DB_NAME`/`DB_USER`/`COOKIE_NAME`) pinning to
+run this repo as UOMysticmoon.
+
+---
+
## Security
**Session & authorization**
diff --git a/bot/.env.example b/bot/.env.example
index 8cd4a01..3f9f590 100644
--- a/bot/.env.example
+++ b/bot/.env.example
@@ -1,4 +1,4 @@
-# ─── UOMysticmoon Discord bot — local dev environment ───
+# ─── Runic Gateway Discord bot — local dev environment ───
# Copy to bot/.env for running `npm run dev` outside Docker.
# (In Docker, the root .env / docker-compose provides these instead.)
#
@@ -40,6 +40,6 @@ SITE_PUBLIC_URL=http://localhost:3000/api/v1/public
# etc.) directly. Point this at the same DB the server/ uses.
DB_HOST=127.0.0.1
DB_PORT=3306
-DB_NAME=uomysticmoon
-DB_USER=uomm
+DB_NAME=runic_gateway
+DB_USER=runic
DB_PASSWORD=change-me-db-password
diff --git a/bot/package-lock.json b/bot/package-lock.json
index 1c1c9d4..b2f3935 100644
--- a/bot/package-lock.json
+++ b/bot/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "uomysticmoon-bot",
+ "name": "runic-gateway-bot",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "uomysticmoon-bot",
+ "name": "runic-gateway-bot",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
diff --git a/bot/package.json b/bot/package.json
index 3796135..7e97402 100644
--- a/bot/package.json
+++ b/bot/package.json
@@ -1,7 +1,7 @@
{
- "name": "uomysticmoon-bot",
+ "name": "runic-gateway-bot",
"version": "1.0.0",
- "description": "Discord bot for the UOMysticmoon community server",
+ "description": "Discord bot for the Runic Gateway community server",
"private": true,
"main": "src/server.js",
"scripts": {
diff --git a/bot/src/brand.js b/bot/src/brand.js
new file mode 100644
index 0000000..bfa72e3
--- /dev/null
+++ b/bot/src/brand.js
@@ -0,0 +1,13 @@
+// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
+// logs carry the instance identity. Kept minimal — the bot only needs the name
+// and the accent color (as an int for discord.js embeds).
+require('dotenv').config()
+
+const name = process.env.BRAND_NAME || 'Runic Gateway'
+const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
+const accentInt = (() => {
+ const n = parseInt(String(accentHex).replace('#', ''), 16)
+ return Number.isNaN(n) ? 0x7f99bd : n
+})()
+
+module.exports = { name, accentHex, accentInt }
diff --git a/bot/src/db.js b/bot/src/db.js
index 5d01a08..a796a45 100644
--- a/bot/src/db.js
+++ b/bot/src/db.js
@@ -12,7 +12,7 @@ const pool = mariadb.createPool({
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
- database: process.env.DB_NAME || 'uomysticmoon',
+ database: process.env.DB_NAME || 'runic_gateway',
connectionLimit: 5,
insertIdAsNumber: true,
bigIntAsNumber: true,
diff --git a/bot/src/discord/commands/rolemenu.command.js b/bot/src/discord/commands/rolemenu.command.js
index e073f17..97736b7 100644
--- a/bot/src/discord/commands/rolemenu.command.js
+++ b/bot/src/discord/commands/rolemenu.command.js
@@ -9,6 +9,7 @@ const {
} = require('discord.js')
const roleMenus = require('../../model/roleMenus')
+const brand = require('../../brand')
// Capped at 5 roles per menu — a single Discord action row holds at most 5
// buttons, and one row keeps this a single simple slash command instead of
@@ -62,7 +63,7 @@ module.exports = {
return
}
- const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2)
+ const embed = new EmbedBuilder().setTitle(title).setColor(brand.accentInt)
if (description) embed.setDescription(description)
const row = new ActionRowBuilder().addComponents(
diff --git a/bot/src/discord/newsAnnounce.js b/bot/src/discord/newsAnnounce.js
index 7f8cad3..0d09232 100644
--- a/bot/src/discord/newsAnnounce.js
+++ b/bot/src/discord/newsAnnounce.js
@@ -4,6 +4,7 @@
const { EmbedBuilder } = require('discord.js')
const guildConfig = require('../model/guildConfig')
+const brand = require('../brand')
const createLogger = require('../utils/logger')
const log = createLogger('news')
@@ -15,7 +16,7 @@ async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl })
const channel = await client.channels.fetch(channelId)
if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.')
- const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url)
+ const embed = new EmbedBuilder().setColor(brand.accentInt).setTitle(title).setURL(url)
if (excerpt) embed.setDescription(excerpt)
if (imageUrl) embed.setImage(imageUrl)
diff --git a/bot/src/internal/requireInternalKey.js b/bot/src/internal/requireInternalKey.js
index efb20c7..3202456 100644
--- a/bot/src/internal/requireInternalKey.js
+++ b/bot/src/internal/requireInternalKey.js
@@ -1,4 +1,4 @@
-// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon
+// Gate for the bot's /internal/* API. The only caller is the main Runic Gateway
// server, over the private compose network — never expose this route through
// the public reverse proxy. Timing-safe compare so response time can't be used
// to brute-force the shared secret one byte at a time.
diff --git a/bot/src/server.js b/bot/src/server.js
index 70cdc7d..582826b 100644
--- a/bot/src/server.js
+++ b/bot/src/server.js
@@ -4,6 +4,7 @@ const app = require('./app')
const bootstrap = require('./bootstrap')
const createLogger = require('./utils/logger')
const discordManager = require('./discord/discordManager')
+const brand = require('./brand')
const pkg = require('../package.json')
const log = createLogger('server')
@@ -11,7 +12,7 @@ const PORT = Number(process.env.PORT) || 4100
const HOST = '0.0.0.0'
async function start() {
- log.info(`starting UOMysticmoon bot v${pkg.version}`, {
+ log.info(`starting ${brand.name} bot v${pkg.version}`, {
node: process.version,
logFile: createLogger.logFilePath || 'disabled (console only)',
})
diff --git a/brand/README.md b/brand/README.md
new file mode 100644
index 0000000..a5d8ef7
--- /dev/null
+++ b/brand/README.md
@@ -0,0 +1,15 @@
+# Brand assets (per-instance)
+
+This directory is bind-mounted into the container at `/app/brand` (see
+`docker-compose.yml`). Drop instance branding images here and point the matching
+`BRAND_*` env vars at them, e.g.:
+
+```
+BRAND_LOGO=/brand/logo.png
+BRAND_HERO=/brand/hero.png
+BRAND_FAVICON=/brand/favicon.ico
+```
+
+Leave the vars blank to use the built-in defaults (the hero falls back to a
+neutral built-in image; no logo/favicon is injected). Nothing here is required
+for the app to run — it renders cleanly with an empty `brand/`.
diff --git a/client/index.html b/client/index.html
index f7d3f29..d92425b 100644
--- a/client/index.html
+++ b/client/index.html
@@ -3,8 +3,8 @@
- UOMysticmoon
-
+ Runic Gateway
+
diff --git a/client/package-lock.json b/client/package-lock.json
index 834e8bf..7118c69 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "uomysticmoon-client",
+ "name": "runic-gateway-client",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "uomysticmoon-client",
+ "name": "runic-gateway-client",
"version": "1.0.0",
"dependencies": {
"@tiptap/extension-image": "^2.27.2",
diff --git a/client/package.json b/client/package.json
index 250c40d..72480e3 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,5 +1,5 @@
{
- "name": "uomysticmoon-client",
+ "name": "runic-gateway-client",
"private": true,
"version": "1.0.0",
"type": "module",
diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx
index 7ca30d1..526fd8e 100644
--- a/client/src/components/SiteFooter.jsx
+++ b/client/src/components/SiteFooter.jsx
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
export default function SiteFooter() {
- const { contactEmail } = useSite()
+ const { contactEmail, siteTitle } = useSite()
return (