feat(brand): BRAND_* env scheme — instance branding without a rebuild
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m20s

Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.

Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
  with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
  API (settings.model.getPublic -> SiteContext), so no client rebuild. The
  admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
  serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
  boot logs, HTML error page. Bot uses it for embed color + logs.

Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.

Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.

Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
This commit is contained in:
2026-07-18 02:20:04 -05:00
parent 1bb9e3c3c3
commit 7a08546da6
49 changed files with 389 additions and 119 deletions

View File

@@ -10,7 +10,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,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,

View File

@@ -14,6 +14,7 @@ const nodemailer = require('nodemailer')
const emailConfig = require('../model/emailConfig/emailConfig.model')
const authProviders = require('../model/authProviders/authProviders.model')
const settings = require('../model/settings/settings.model')
const brand = require('../config/brand')
const log = require('./logger')('mailer')
// Ready to send only when enabled, connected (has a refresh token), and we know
@@ -76,7 +77,7 @@ async function sendContactMessage({ name, email, message }) {
from: fromHeader(config),
to,
replyTo: email,
subject: `UOMysticmoon contact from ${name || 'a visitor'}`,
subject: `${brand.name} contact from ${name || 'a visitor'}`,
text: `From: ${name || 'unknown'} <${email || 'no email'}>\n\n${message}`,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Last send OK', lastVerifiedAt: new Date() })
@@ -110,7 +111,7 @@ async function sendTest(to) {
await transport.sendMail({
from: fromHeader(config),
to: recipient,
subject: 'UOMysticmoon email test',
subject: `${brand.name} email test`,
text: 'This is a test message confirming Gmail OAuth2 email delivery is working.',
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Test send OK', lastVerifiedAt: new Date() })
@@ -139,9 +140,9 @@ async function sendInvite({ to, acceptUrl, role, invitedByName }) {
await transport.sendMail({
from: fromHeader(config),
to,
subject: 'Your UOMysticmoon invitation',
subject: `Your ${brand.name} invitation`,
text:
`You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` +
`You have been invited${by} to join ${brand.name}${roleLabel}.\n\n` +
`Accept your invitation and set up your account here:\n${acceptUrl}\n\n` +
`This link is single-use and will expire. If you weren't expecting this, you can ignore it.`,
})

View File

@@ -9,7 +9,9 @@
const speakeasy = require('speakeasy')
const QRCode = require('qrcode')
const ISSUER = process.env.TOTP_ISSUER || 'UOMysticmoon'
const brand = require('../config/brand')
const ISSUER = process.env.TOTP_ISSUER || brand.name
// Generate a new secret. Returns the base32 secret to persist plus the otpauth
// URL to encode in a QR code.