Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).
- Schema: new server-owned `appeals` table (no cross-owner FK to
mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
eligible/withdraw) and admin queue handlers (list/claim/resolve/
per-user) under the existing admin+moderator gate; one-active-appeal
enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Rework the README's Pangolin-specific deployment section to cover any
reverse proxy (Nginx, Caddy, Traefik, Pangolin). Add TRUST_PROXY /
X-Forwarded-* guidance and Nginx + Caddy config snippets, keeping
Pangolin as one documented example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.
Location is now privileged data:
- Server: getOnline inspects the caller's role via getUserFromRequest
(the same non-rejecting helper siteMode uses on public routes) and
only includes map/x/y/z for admin/moderator callers. For everyone
else the fields are omitted from the JSON entirely, so they can't be
read from the network tab. serial + name (online status) still shown.
- Client: Shard.jsx gates the location span on the viewer's role from
useAuth() (same pattern as RoleGate); non-privileged viewers see who
is online but no location field is rendered.
Tests: publicShardOnline.test.js covers admin + moderator (location
included), player + unauthenticated + editor (location omitted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Address review: the SVG footer badge rendered too small, and the default
hero should feature the Runic Gateway emblem rather than the moon.
- Footer: drop powered-by.svg; render the emblem PNG beside a "Powered by
Runic Gateway" label (left-justified, info text stays centered).
- Default hero: brand.hero (and the client fallbacks) now default to the
emblem PNG. The untouched default hero centers the square emblem behind
the text as a medallion with a symmetric legibility overlay (per-layer
background-size so the overlay stays full-bleed). BRAND_HERO still
overrides.
- Admin login / player shell / maintenance backgrounds center the emblem
as a capped medallion instead of a cropped full-bleed cover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Ship the Runic Gateway emblem as the baked-in default favicon so an
instance renders a tab icon with no BRAND_FAVICON set, and place a
left-justified "Powered By" badge in the site footer.
- brand.favicon now defaults to /assets/img/favicon.ico (was empty).
BRAND_FAVICON still overrides per-instance.
- Add favicon.ico + powered-by.svg under client/public/assets/img.
- SiteFooter: badge pinned to the left of the centered content column
(absolute on >=641px, stacked on mobile); info text stays centered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
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.
Extracted BACKEND_DESIGN.md, HERO_EDITOR.md, and WIKI_UPGRADE.md into the
central RunicGateway/docs repo (under docs website/, full history preserved
via git filter-repo). Repoint the README's two BACKEND_DESIGN.md links at
the new location.
Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
Repo was transferred UOM -> RunicGateway. build-images.yml already
derives its registry owner from github.repository_owner, so it now
publishes to gitea.whitlocktech.com/runicgateway/*, but docker-compose
still pulled from /uom/*. Point the app+bot images at the new owner so
deploys pull the images CI actually publishes. Also update the two
README links to the uo-link repo (UOM/link -> RunicGateway/link).
No branding text touched (that is handled separately).
Follow-ups from live testing:
- Game-account creation is now an admin Settings control (disabled / website /
hybrid / game) instead of a hidden on/off flag. The site offers creation for
website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
"Email the invitation" toggle lets an admin create a link-only invite (no email)
or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
(POST /admin/shard/account → the shared createGameAccount controller), so the
form is reachable in both the player and admin portals.
Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).
Client build clean; server routes load; swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per request, split the single public house registry into three role-scoped views:
- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
kept live via the public house.decay feed. The full-registry deltas
(house.update / house.remove — which carry owner/price) are REMOVED from the
public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.
Server tests green, client build clean, swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ensureSchema() splits schema.sql on ';' and is not comment-aware, so the inline
comments "epoch ms; NULL = current" and "not in the feed; reserved" shattered the
CREATE TABLE into invalid fragments (ER_PARSE_ERROR on a fresh boot). Reworded both
to drop the semicolons. Caught during live-stack bring-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6: the UI for the Phase 5 provisioning backend.
- CreateGameAccountForm: reusable game-account form (own username + password),
mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
GameAccounts (self-serve) — shown alongside the [link flow when the
game_account_signup flag is on (exposed via public settings), so a registered
player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
access level, list invites with status, revoke pending ones. When email isn't
configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
password (email + role pre-assigned), creates the account at that role and logs
in; for a player invite it then offers the built-in "create game account" step
before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.
Client build clean; server 193/193.
Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.
- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
shard (hashed there) and never stored/logged; the end-user browser IP is passed
for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
username + password) for the signed-in user and mirrors the link locally. Gated
by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
mailer.sendInvite (falls back to returning the accept link if email is off);
public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
user at the invite's preset role and logs them in, bypassing the registration
gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
mirror drop; account.unlinked ingest reconciles the mirror when a player runs
[unlink in game. account.audit / account.unlinked are logged (admin channel
only — never on the public SSE allowlist).
Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4: sync the site's published news posts into the Protocol 2.1 News gump.
- uoLinkClient.postNews / deleteNews.
- utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article
stays in the gump while its post is published news and is pulled when it leaves
that state. buildArticle renders a compact gump-HTML block (centred title +
plain-text excerpt — the gump supports only a small HTML subset) with a
"more info" link to /site/news and an optional gump image from the
`news_gump_image` setting. Every call is best-effort / never-throws.
- Hooked into the posts pipeline alongside the existing announce enqueue:
syncPost on create/update/publish (fresh publish announces; edits refresh
silently; leaving published-news pulls the article), removePost on delete.
- reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect —
reconciles the gump to our source of truth and recovers any article whose
original live push failed (silent, so a reconnect never re-proclaims old news).
Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: surface the new board data on existing character/user pages.
- Character sheet: render the char.profile titles block (fame/karma + skill +
selected reward title; numeric clilocs skipped since the site has no cliloc
table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
verifiable from current board state, whereas guessing membership from stale
guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
registry fields (decay level, placement price, co-owner/friend counts) already
returned by listHousesForAccounts.
Server 179/179, client build clean, swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2: the public UI for the four new boards, following the ChampSpawns live
pattern (snapshot via useAsync + merge SSE deltas with useShardFeed).
- Players Online widget (components/PlayersOnline.jsx): total + region breakdown
rolled up into display buckets (data/regionBuckets.js — the one place to retune
the grouping); live via presence.online. Placed on the Shard page, replacing the
static players-online stat tile.
- Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a
"recently joined" strip from guild.join.
- Governors (/site/governors): one card per city with a placeholder crest
(data/cityCrests.js — swap for real art without touching components), election
phase badge + autoPickAt countdown, and an on-demand "past governors" term
history (the look-back reads the ledger captured in Phase 1). Clean empty state
when City Loyalty isn't enabled.
- Houses (/site/houses): searchable registry with decay badges; price labelled
"placement value", not a for-sale flag.
- API client methods + nav links (Guilds / Governors / Houses).
Client build clean (240 modules).
Refs .plans/protocol2-integration.md (Phase 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).
- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
extend shard_houses with the house.update registry columns (owner_name,
co_owners, friends, price, decay, in_registry) so the decay-transition and
registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
house.update/remove; log guild.join (real-time joins feed); region.enter is
broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
the open term is closed and a new one opened, idempotent so backfill/duplicate
city.update never spawn spurious terms. votes stays NULL (the feed carries only
candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
board on every WS (re)connect, independently guarded so an empty/failed board
(e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
(15 new; full suite 179/179). Swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deploy job referenced a runner labelled `uom_deploy`, which doesn't
match the actual self-hosted runner (`uom-deploy-runner`), so the job
would queue forever waiting for a runner that never picks it up. Update
the `runs-on` label (and the two doc comments) to the real label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a `deploy` job to build-images.yml that runs on the self-hosted
`uom_deploy` runner and, via `needs: build`, fires only after a clean
image build+push. It pulls the fresh :latest images and recreates the
stack (pull → down → up -d) from /home/perry/website. Guarded on
refs/heads/main so a workflow_dispatch off another branch can't deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On `docker compose up`/restart the bot and app start together. The bot's
`depends_on: app` uses `condition: service_started`, which only waits for the
app container to launch — not for its internal server (3001) to be listening
after it reaches the DB and boots Express. bootstrap.js did a single un-retried
fetch, lost that race, gave up, and left the bot disconnected while the DB
`enabled` flag stayed true — so the admin panel showed "enabled but
disconnected" until an admin toggled off/on to force a pushConfig.
Retry the boot config fetch with backoff (~1 min, 2s apart) until the app
answers: retry on network errors and 5xx, bail on 4xx (a real misconfig, not a
startup race). Also wrap discordManager.start in try/catch so a bad-token boot
logs and keeps the process alive instead of crashing it via server.js's
exit-on-start-failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.
Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Wire up the three uo-link sidecar surfaces that weren't integrated yet.
Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
our own store, like online/houses); public /site/champs board with a nav link,
live via the existing SSE feed (champ.* added to the public allowlist).
Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
from the session, never the browser. Sidecar status codes mapped (403 disabled/
protected, 404 unknown, 503/504 transient). admin.audit events are logged and
surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
on the user-detail and character views (ShardAccountActions, self-gated to staff).
Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
/admin/shard/pages/*. Champ board and page queue are snapshotted from the
sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
never wipes local state).
Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Add .gitea/workflows/pr-checks.yml running on pull_request into main.
Three parallel jobs on the existing ubuntu-latest runner:
• server-tests — npm ci + node --test (164 tests, no DB needed:
the suite stubs models and points the pool at a dead port)
• client-build — npm ci + vite build
• bot-install — npm ci only (catches a broken/stale lockfile)
No ESLint exists in the repo yet, so no lint step. Complements
build-images.yml, which publishes images post-merge.
Enable in Branch Protection with status check pattern: PR Checks / *
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Make the base docker-compose.yml strictly production-shaped — image: only, no
build: — so a production host can only ever pull, never accidentally build
(compose gives build precedence for `up --build`/`build`, which mixed the two
modes). Local builds move to an explicit, non-auto-loaded overlay.
Production: docker compose pull && docker compose up -d
Development: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
Verified with `docker compose config`: base renders image-only (no build) for
both services; the dev overlay adds build back (app -> Dockerfile,
bot -> bot/Dockerfile). README quick-start updated to the two-file flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Point the `app` and `bot` services at the images published to the Gitea
registry by the build-images workflow, so deploys pull instead of building:
image: gitea.whitlocktech.com/uom/website-app:${IMAGE_TAG:-latest}
image: gitea.whitlocktech.com/uom/website-bot:${IMAGE_TAG:-latest}
`build:` is kept, so `up --build` still works locally; the server runs
`docker compose pull && up -d`. IMAGE_TAG defaults to `latest` for routine
deploys and pins to an immutable `sha-<7>` build for reproducible deploys /
rollback — no per-deploy compose edits. Documented in .env.example + README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ