Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.
The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.
The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.
The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.
Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.
- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
process, invalidated on a brand_assets/theme_visual write with a 5-minute
TTL so other workers converge. A settings-read failure renders the
env-only shell and caches that, so a DB outage is not a failing query per
page view, and with no rows the output is byte-identical to what app.js
served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
row in the same call, so an upload never leaves an unreferenced file. It
reuses the shared multer allowlist and only tightens it per slot: favicons
are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
/uploads, /brand or /assets — these are the only settings values written
straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
block, removing the first-paint flash phases 3-4 deferred; SiteContext
drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
when no logo is set, which is the shipped default.
Co-Authored-By: Claude <noreply@anthropic.com>
"Trust this device" did nothing for anyone who signs in with Google or Discord.
sso.controller went straight from needsTotp(user) to staging a pending-TOTP
challenge and never consulted resolveTrustedDevice, so an SSO user was asked for
a code on EVERY sign-in no matter how many times they had ticked the box — and
POST /auth/sso/totp accepted only `code`, so that step could not establish a
trust either. The password paths (web + native) were unaffected and already
worked; this closes the gap for SSO, on the website AND in the Android app.
Server:
- finishLogin and finishMobileLogin now run the same trusted-device check as
auth.controller.login, via one shared helper: honor a trust that belongs to
THIS user, stamp last_used_at, log auth.login.trusted_device. A store error
falls through to the challenge — fail closed to asking for the code.
- POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust
cookie, and mirrors the password path's { trustLimitReached, devices } response
at the cap (the sign-in still completes). Recovery codes stay password-only.
Android coverage, without leaking a secret into a URL:
- The app opens SSO in a Custom Tab, which shares the system browser's cookie
jar, so the rg_trust cookie set on that TOTP form is presented back on the next
app sign-in. That alone makes native SSO skip the code. Passing the app's token
into the start URL was rejected — it would put a 256-bit secret in query
strings, Referer headers and access logs.
- To also cover the app's NATIVE password login, ticking the box sets
mobile_auth_sessions.trust_device (a boolean; never the token), and
/auth/mobile/sso/exchange mints a platform:'mobile' trust and returns
{ trustToken }. Minting there keeps the raw token on an authenticated
app→server call, out of the deep link and out of the bridge row. Best-effort:
at the cap the response just omits it rather than failing a good sign-in.
Client: the trust checkbox is no longer hidden on the SSO second step, on both
the admin and player login screens. On the mobile bridge the deep-link redirect
takes priority over the cap prompt — the sign-in succeeded and the link is
single-use, so stalling there would strand the app.
Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail
against the pre-fix controller). Full suites green — server 445, client 43 —
and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on
/auth/sso/totp in routes.guards.json for the two new validators. Swagger
regenerated. Verified live against the running server and real MariaDB: the TOTP
step issues rg_trust and persists the row, a subsequent SSO callback carrying it
skips the code, and an invalid trust is still challenged.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.
By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
drops under the threshold — shard model upsert builders, page/wiki update,
block validation, notification stream mapping (dispatch table), SSO mobile
login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
(two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that
parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
prefix list): assorted one-liners.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.
- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
additive via the ALTER section; seeded to now on insert). With single-use
rotation each login/refresh inserts a fresh row, so the active row's timestamp
is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
/auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
device out), plus the PlayerLogin change to honor the mobile SSO bridge's
{ redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
server suite green (274); client builds.
Co-Authored-By: Claude <noreply@anthropic.com>
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.
Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
hash and revokes every session (web cutoff + mobile refresh tokens); it does
not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.
Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
/account/reset/:token, and a "Forgot your password?" link on the login page.
Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
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
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
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.
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>
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>
Implements the "Frontend Theme Redo" design (decision 1a): the logged-in
player portal now uses the same sidebar shell as Admin, and Admin's own
My Characters view gets the same stat-tile treatment.
- PlayerPortalLayout: replace the light 820px top-tab header with the
Admin sidebar shell (icon nav, sticky content header with page title,
signed-in footer with sign out). Reuses .admin-grid so the two
logged-in experiences read as one app.
- Drop the now-redundant inner <h1> from PlayerCharacters/PlayerAccount;
the title lives in the sticky header.
- CharacterStats: new stat-tile row (Characters / Online now / Linked
account) that tolerates a restarting shard and hides until an account
is linked.
- AdminCharacters: render CharacterStats above the roster instead of the
bare intro paragraph, matching the Player Portal Characters page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".
Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.
Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
staff self-service, reusing the player/shard controller (it keys off
req.user.id, so the same handlers serve any logged-in role). Swagger under
Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
into one reusable component parametrized by an api scope and a charTo(serial)
route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
/admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
self-service scope. api.admin.shard.* added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.
- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
resistances, skills (with bars), and equipment — styled with the shared
panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
logged-in player if they haven't linked a game account (with the [link code
prompt) or, once linked, shows their characters grouped by account; each
character opens its sheet at /player/char/:serial. Account security moved into
the same shell (the buried "Game accounts" block was removed from it).
Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
"Online now" list on /site/shard that links to public character sheets at
/site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.
Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Frontend for the uo-link integration, matching the existing site styling.
- api/client.js: api.shard.* (status/feed/economy/idoc/char), the
shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/
vendors).
- lib/useShardFeed.js: EventSource hook over /public/shard/stream with a
rolling buffer and a connected flag (browser never touches the sidecar WS).
- routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply
/ link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses"
lists, and a live event ticker — built from the shared panel/grid/format
vocabulary. Registered at /site/shard under the maintenance gate and linked
from the site header.
- routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link
code to link an account, then expand it to see characters and player vendors
on demand (503 shows a retry banner).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
/admin) so you land in the right shell whichever door you used.
Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
- Player portal: RequirePlayer guard, /account routes (login, register,
settings) with shared PlayerShell; register reads /public/settings derived
flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
status column + badge-player in UsersAdmin, player_registration select in
SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
statement splitter in ensureSchema.
Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV