Commit Graph

88 Commits

Author SHA1 Message Date
1a61cd1638 build(swagger): normalize and sort generated OpenAPI path keys
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m16s
Prepares the committed spec for the admin router domain split
(docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's
output in swagger/swagger.js. No route, handler or annotation changes.

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('/') documents as
/api/v1/admin/users/ — advertising a URL no client calls while dropping the one
the SPA, the Android app and the Discord bot all do. Express is indifferent
(non-strict routing treats the two as one route, and routes.manifest.json records
the canonical slash-less form), but the published spec is a contract. The split
creates one of these per capability router, so it is fixed once here rather than
by contorting the route declarations in every router file.

Path keys are also sorted. The generator emits them in router-traversal order, so
moving a route between files rewrites most of this ~5k-line committed artifact
even when the API is provably unchanged, burying the one line a reviewer needs to
see. OpenAPI attaches no meaning to path order, and scripts/routeManifest.js
already sorts for the same reason.

Verified inert: the regenerated spec is byte-for-byte the sorted form of the
previously committed one — same 198 operations, zero added or removed, and no
trailing-slash keys (there were none to strip yet; the guard is for the split).
A collision after normalization throws rather than silently dropping an
operation. Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:49:31 -05:00
9b74999610 feat(security): soak the tightened CSP on report-only, with a same-origin sink
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on
Content-Security-Policy-Report-Only alongside the unchanged enforced one for a
release; a follow-up PR flips it after the soak comes back clean.

The plan expected a two-directive delta. It is one. `form-action 'self'` was
described as absent because it is not in the directives object in app.js — but
the middleware runs with `useDefaults: true` and helmet's defaults already
supply it, so the header served in production has carried it all along. Caught
by capturing the live header from the running app instead of reading the config.
It is now written out explicitly in config/csp.js regardless: a security
directive should not depend on a third-party library's default surviving its
next major version. The enforced header's contents do not change at all, and a
test pins it verbatim.

So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is
still the directive most worth soaking: a frame-ancestors report is generated by
the browser of whoever framed the site, which is the only way to find out that
something legitimately embeds us before an enforcing policy breaks it.

The policies move to config/csp.js, with the report-only one derived by spread
from the enforced one so the two cannot drift and the object reads as a diff.

`report-to` needs somewhere to point, so this adds POST /api/csp-report --
same-origin on purpose, since reports describe attacks against this site and
should not go to a third-party collector. It is mounted outside /api/v1 next to
/api/health: the browser learns the path from the policy header, never from a
client build, so it is not versioned client contract.

It is necessarily unauthenticated -- browsers send reports with no session, and
gating it would silence exactly the anonymous visitors worth hearing about -- so
it is bounded on every axis:

  * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys
    in application/csp-report and report-to (Chrome) sends camelCase envelopes
    in application/reports+json; handling one silently drops half the browsers,
  * report-to also needs the Reporting-Endpoints response header or it is inert,
  * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field
    truncated (script-sample is attacker-influenced and can carry a whole inline
    script),
  * always 204, even for malformed input: a 4xx would reach the global error
    handler, which logs the offending body -- turning an open endpoint into a
    log-flood primitive.

Nothing is persisted; reports go to the `csp` log tag.

routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as
designed: the one new URL is visible as a reviewed +1 rather than slipping
through. Swagger regenerated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:26 -05:00
1079b3fc05 chore(server): freeze the URL surface with a generated route manifest
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m40s
PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.

scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.

Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.

Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.

The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.

CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:55:38 -05:00
c075ab981c fix(moderation): windowValue must not fall back to the 30d total on a null column
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 39s
windowValue mapped only the 24h/7d keys and used `?? row.d30` as the fallback:

    const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30

so a null d1/d7 (which the function is documented to tolerate) returned the
30-day count instead of 0, inflating the 24h/7d moderation tiles. It happens to
be masked today because `SUM(created_at >= ?)` nulls d1/d7/d30 only in unison,
but the contract is wrong and the existing test used an all-null row that hid it.

Map all three window keys explicitly so each reads its own column and a null
coerces to 0 via `Number(col) || 0`. Add a regression test with a null narrow
column and a non-null d30.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:14:02 -05:00
e08c0c9736 fix(admin): restore digit match in discordId route validation
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m30s
The `:discordId` param validator on the five admin moderation routes used
`/^d{1,32}$/`, which matches 1-32 literal `d` characters instead of digits.
A real numeric Discord snowflake failed validation, so every
`/moderation/user/:discordId*` endpoint returned a 400 for valid input.

The backslash was dropped in a prior code-smell cleanup (12d50fd) that
intended `[0-9]` -> `\d`. Restore `\d` so the regex matches digits again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:01:12 -05:00
14dfc122ba fix(player): open the player self-service surface to staff
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 9m28s
Staff are a superset of players — every player ability plus their staff
tools on top — but the /player/* group ran requireRole('player'), so a
signed-in admin/editor/moderator got 403 on their own linked game
accounts (e.g. GET /player/shard/accounts). On the Android client this
hid "My characters" and greyed the personal notification streams for
staff accounts, even when they had linked characters.

Drop the role gate: the group is now requireAuth-only. Every handler is
already self-scoped to the caller by req.user.id (with the pre-existing
isAdmin bypass still letting a genuine admin read any character), so this
only ever widens access to the caller's OWN data. Staff also reach the
identical self-scoped handlers under /admin/shard/* (same controller).

- player.routes.js: requireRole('player') -> requireAuth; corrected the
  five stale "Player role required" 403 descriptions and regenerated
  swagger-output.json.
- New test/playerRouteAccess.test.js mounts the router and asserts
  player/admin/editor/moderator all reach the handler, anon still 401s,
  and a disabled account still 403s. Suite: 420 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:17:29 -05:00
60ebacff2c feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
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>
2026-07-21 23:38:48 -05:00
401db8f75c refactor(server): dedupe shard-state shaping, upsert builder, and config DB models
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 11m4s
Address the SonarQube copy-paste findings that reflect real duplication (as
opposed to the intentional cross-package / admin-player mirror copies, which
are by-design and left as-is):

- shardState.model.js: listOnline() re-inlined the exact field mapping that
  shapeOnline() already provides (used by listOnlineLinked). Collapse it onto
  shapeOnline so the two can no longer drift.
- shardState.db.js: extract a single upsertRow(table, pkCol, pk, fields,
  {coalesce}) builder for the five near-identical INSERT ... ON DUPLICATE KEY
  UPDATE bodies (online/houses/champs/guilds/governors). shard_online keeps its
  COALESCE-on-NULL semantics via the coalesce flag.
- botConfig/emailConfig/uoLinkConfig .db.js: generate get()/upsert() from a
  shared singletonConfigDb(table, cols) factory instead of three byte-identical
  copies.

Behavior unchanged; full server suite (381 tests) passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 12:35:56 -05:00
12d50fd615 chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
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>
2026-07-21 04:35:39 -05:00
35e5269ec5 test(server): unit-test auth, invite, password-reset, and public controllers
Add controller-level unit tests (mock req/res, monkeypatched collaborators)
focused on security boundaries and decision logic the API must not regress:

- auth.controller: honeypot handling, non-enumerating generic-fail for every
  credential failure, inactive-account refusal, the TOTP challenge branch that
  must NOT issue a session, register-mode gating + dup-username 409, and logout
  that always clears the cookie and revokes the session (even on error).
- invite.controller: user created at the invite's PRESET role, and the lost
  double-accept race rolling back the just-created user.
- passwordReset.controller: identical generic 200 whether or not the email
  matched (incl. internal errors), per-account mail-failure isolation, the
  single-use consume race, and revoke-everywhere-on-reset with no auto-login.
- public.controller: staff-only draft visibility, token-gated page preview,
  wiki search precedence + unknown-filter handling, contact 502.
- shard.controller (public): the PUBLIC_KINDS feed allowlist and the public
  house view stripping owner/price — both leak-prevention boundaries.

Lifts: auth.controller 46%→94%, passwordReset 33%→93%,
public.controller 28%→65%, shard.controller 45%→68% line coverage;
server aggregate 63.5%→70.4%.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:33:01 -05:00
99fd9acddb test(server): unit-test pages, shardState, and moderation model logic
Add meaningful unit tests for three server models with untested business
logic, each against an in-memory fake db (no DB required):

- pages.model: slug validation + reserved-name guard, slug immutability,
  the protected ON-via-update / OFF-only-via-unprotect asymmetry, publish
  stamping, draft invisibility to public reads, dup-slug → 409, block gate.
- shardState.model: partial-refresh field dropping (vitals must not clobber
  login fields), is_idoc derivation, economy clamp/ordering/Number coercion,
  presence zero-snapshot defaults, payload-fallback shaping, and the
  camelCase read-shaping contract the site + Android client depend on.
- moderation.model: five-feed window merge, userSummary count/total
  semantics (total sums unknown types too), and graceful degradation when
  bot config is missing.

Lifts: pages.model 23%→82%, moderation.model 27%→85%,
shardState.model 33%→62% line coverage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 00:26:01 -05:00
e1461d9161 fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash
Address SonarQube security hotspots on the website:

- server/src/app.js: replace `contentSecurityPolicy: false` with a helmet CSP
  tuned for the built React SPA (script-src 'self'; style-src adds 'unsafe-inline'
  for React inline styles + the Google Fonts stylesheet; font-src gstatic; img-src
  allows data:/https: for uploads, embedded body images and BRAND_* assets;
  connect-src 'self' for REST+SSE). upgrade-insecure-requests is intentionally
  omitted (TLS terminates at the proxy; keeps local `npm start` over http working).
  The /api/docs Swagger UI route gets a scoped looser policy (inline script/style)
  since swagger-ui-express injects an inline bootstrap.
- client/vite.config.js: disable the inline module-preload polyfill so code-split
  builds keep `script-src 'self'` valid (RichTextEditor is a separate chunk).
- bot/src/app.js, server/src/internalApp.js: disable x-powered-by on the two
  internal-only listeners (the public app already strips it via helmet).
- shardEvents dedupe key: SHA-1 -> SHA-256 truncated to 40 hex chars (fits the
  existing CHAR(40) column, no migration; it is a content fingerprint, not a
  security value). schema.sql comment updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 23:02:11 -05:00
d1b3351360 fix(db): strip inline -- comments before splitting schema statements
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m18s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m26s
The schema loader stripped only full-line -- comments, then split the
file on ';'. A trailing comment containing a semicolon (e.g. the
mobile_auth_sessions.session_id column: `-- uuid; carried inside...`)
chopped the CREATE TABLE in half, so MariaDB got the fragment and failed
with `error ... near '' at line 3`, crash-looping the server on boot.

Strip -- comments on every line (full-line and trailing) before the ';'
split. Safe because the schema never places -- inside a string literal.

Verified by running ensureSchema() against a fresh MariaDB: all 49 tables
create cleanly and mobile_auth_sessions has all 11 columns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 19:10:53 -05:00
bcc96e7cfb feat(mobile-sso): serve assetlinks.json + App Links redirect allowlist
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m23s
PR Checks / client-build (pull_request) Successful in 10m6s
PR Checks / bot-install (pull_request) Successful in 9m16s
Add the server side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md):

- GET /.well-known/assetlinks.json at the web root, gated by the new admin
  setting `mobile_app_links_enabled` (default off -> 404; on-but-no-fingerprint
  -> 404). Emits the Digital Asset Links statement for the fixed published
  package (MOBILE_APP_PACKAGE) + MOBILE_APP_CERT_SHA256 fingerprint(s).
- mobileSso `/start` additionally accepts this shard's own self-origin
  https://<host>/mobile/callback when App Links are enabled — one additive
  exact-match entry, derived from APP_BASE_URL/request origin, never client
  input; the custom-scheme allowlist is never narrowed. The settings lookup is
  short-circuited for non-https redirects so custom-scheme rejections stay fast.
- settings.isMobileAppLinksEnabled() (fail-closed) + getPublic().mobileAppLinks;
  admin updateSettings validates the boolean; seed default off.

Tests: test/appLinks.test.js (route gating + allowlist). Full suite 284 pass.
Swagger unchanged (web-root verification file is #swagger.ignore'd).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:17 -05:00
e3dd5358b6 feat(auth): Active Devices — view/revoke mobile sessions
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m16s
PR Checks / bot-install (pull_request) Successful in 9m17s
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>
2026-07-20 17:01:47 -05:00
61f4591a6b feat(auth): native SSO authorization bridge for the Android app
Add a Mobile SSO Authorization Bridge so the native app can "Sign in with
Google/Discord" without shipping any OAuth secret. It EXTENDS the existing
/auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning,
TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel
auth path.

- Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning;
  authorization code stored hash-only, PKCE challenge is a hash by construction).
- GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT
  allowlist match (never prefix), seed a bridge session, reuse the SSO redirect
  tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow).
- SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use,
  hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed
  state, never a token) instead of setting a cookie. 2FA keeps full parity via
  the existing web TOTP form (now carrying the bridge session).
- POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code),
  single-use consume, then issue the SAME pair as /auth/mobile/login.
- Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*.
- Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time +
  opportunistic prune of both tables (no cron, mirrors revoked_sessions).
- Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed
  runicgateway://auth/callback); App Link URIs can be appended per shard later.
- Swagger regenerated; 39 tests (model single-use/gating + full controller
  matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect
  allowlist, TOTP-through-bridge). Full suite green (271).

Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 16:55:06 -05:00
a789ee3ac9 feat(settings): surface push.ntfyUrl in /public/settings for the app
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m39s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / bot-install (pull_request) Successful in 9m17s
The Android app's embedded push distributor (M7 Part 2) needs the shard's
client-facing ntfy relay URL to build its device topic endpoint, but the M7
Part 1 backend only used the NTFY_* vars server-side and never surfaced them.

Add a `push: { ntfyUrl }` block to settings.getPublic(), sourced from
NTFY_PUBLIC_URL or the first NTFY_ALLOWED_ORIGINS entry (never the possibly
internal NTFY_BASE_URL); null when unconfigured, so the app shows push as
unavailable for that shard. Additive, non-sensitive, forward-compatible.

- Extend the PublicSettings swagger schema; regenerate swagger-output.json.
- publicBrand.test.js: cover null / NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS.
- Document NTFY_PUBLIC_URL in .env.example and (docs PR) BACKEND_DESIGN.md.

Full server suite green (250 pass).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 15:25:40 -05:00
416761f8f7 feat(push): M7 backend — opt-in push notifications via self-hosted ntfy
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 9m21s
PR Checks / bot-install (pull_request) Successful in 9m17s
Additive, v1-only backend contract for the Android app's opt-in push (Part 1 of
M7; docs/android/PLAN.md §11). The app is a pure consumer — this lands the
endpoints, fan-out, and relay it needs.

- Schema: push_devices (per-device endpoint) + notification_subscriptions
  (per-user opted-in streams), FK→users ON DELETE CASCADE.
- Stream catalog + event→stream mapping (config/notificationStreams.js): public
  streams (news.post, server.status, idoc.warning, champ.start, governor.election)
  drawn ONLY from the SSE PUBLIC_KINDS allowlist; personal owner-keyed streams
  (vendor.sale, house.idoc, account.login). Full-state upserts (champ/city) fire
  only on a real transition via an injectable tracker.
- Fan-out (utils/pushDispatch.js): content-free tickles ({ stream, ref }) POSTed
  to each subscribed device; never throws. Two producers — shardIngest.ingest
  (beside the SSE broadcast) and the create/publish-post path (news.post).
  Personal events resolve to the owner via shardLinks. SSRF guard: endpoints must
  be HTTPS, non-private, and on the NTFY_BASE_URL/NTFY_ALLOWED_ORIGINS allow-set —
  enforced at registration and every publish.
- Routes under the role-agnostic self surface (never /admin): POST|GET
  /auth/me/devices, DELETE /auth/me/devices/:id, GET
  /auth/me/notifications/streams, GET|PUT /auth/me/notifications/subscriptions.
  Swagger regenerated (4 paths, PushDevice/NotificationStreams/etc. schemas).
- ntfy service in docker-compose.yml: pinned image, declarative ./ntfy/server.yml,
  no published host port, anonymous unguessable topics (no accounts) — zero
  interactive setup. No publish token required (content-free design); optional
  NTFY_PUBLISH_TOKEN honored.
- Tests: pushDispatch (mapping, PUBLIC_KINDS gate, owner-keying, SSRF guard,
  content-free payload) + notifications route auth gate. Full suite green (247).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 05:13:48 -05:00
c35509e8b3 feat(public): type the brand block so mobile clients get typed theming
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m29s
PR Checks / server-tests (pull_request) Successful in 10m30s
PR Checks / bot-install (pull_request) Successful in 9m21s
Branding is already returned by GET /public/settings (the `brand` block:
name/colors/logo/hero/favicon, per-shard from BRAND_*). §8.6 of the Android
plan asks to confirm it — this makes it a first-class part of the contract so
the app's OpenAPI codegen produces typed branding instead of an untyped map.

- Swagger: add Brand + PublicSettings schemas; /public/settings now references
  PublicSettings (was additionalProperties:true). Brand documents that asset
  fields may be site-relative paths (resolve against the base URL).
- test/publicBrand.test.js locks the brand theming contract the app depends on
  (all fields present; BRAND_* defaults; admin site_title/contact_email
  overrides; accentInt never leaked).

No behavior change to the response — it already carried `brand`; this types and
guards it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:58:01 -05:00
90c8eae20f feat(public): version/health surfacing for the app first-run probe
Expose a small backend identity/version descriptor (§8.4 of the Android plan)
so a client can positively recognize a Runic Gateway backend on first-run and
run a version-mismatch guard, instead of inferring from an incidental shape.

- New config/version.js: { service: 'runic-gateway', api: 'v1', server: <pkg> }.
- GET /public/status now includes a `version` block (the app already calls this
  on first-run, so it gets identity + version in one round trip).
- New GET /public/version: a lightweight, DB-free identity endpoint — the
  canonical target for the version guard and a cheap liveness check.
- Swagger: PublicVersion schema + version on PublicStatus; /version annotated.
- test/publicVersion.test.js covers the config shape and the DB-free 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 11:47:26 -05:00
fc5255da99 feat(auth): role-agnostic self-service surface under /auth/me
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m27s
PR Checks / client-build (pull_request) Successful in 10m23s
PR Checks / bot-install (pull_request) Successful in 9m19s
Add /auth/me/account* — the canonical "me" endpoints for every authenticated
role (Android app §6.4/§8.1). Reuses the existing account.controller handlers
(getAccount, changeUsername, changePassword, TOTP setup/enable/disable, list/
unlink identities) verbatim behind requireAuth (any role) — no logic
duplication. The app gets one self surface and never has to touch /admin; the
old /player/account/* and /admin/account/* routes stay for web back-compat.

New routes (all bearer- or cookie-auth, any active role):
- GET    /auth/me/account
- PATCH  /auth/me/account/username
- PATCH  /auth/me/account/password
- POST   /auth/me/account/totp/setup|enable|disable
- GET    /auth/me/account/identities
- DELETE /auth/me/account/identities/:provider

Mounted as a sub-router; the bare GET /auth/me is unchanged. Swagger
regenerated with #swagger annotations. Adds test/authMe.test.js (the group
gate rejects unauthenticated callers with 401).

Verified end-to-end against MariaDB: a player and an editor both drive the
same surface (role-agnostic), username/password changes work, a password
change revokes the caller's old bearer token, and validation/401 paths behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 04:55:01 -05:00
250cb1e2d3 Merge branch 'main' into feat/password-reset
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m26s
PR Checks / bot-install (pull_request) Successful in 9m31s
2026-07-19 09:04:43 +00:00
10aed49bb6 feat(auth): self-service password reset (backend + web)
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
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
2026-07-19 03:57:13 -05:00
028ba8c5e4 feat(moderation): appeals (6c) + Discord reversal on approve (6d)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m59s
PR Checks / client-build (pull_request) Successful in 9m32s
PR Checks / bot-install (pull_request) Successful in 9m37s
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
2026-07-18 22:01:06 -05:00
aa2177715e fix(shard): restrict staff in-game location to admins/moderators
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m23s
PR Checks / server-tests (pull_request) Successful in 10m33s
PR Checks / bot-install (pull_request) Successful in 9m18s
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
2026-07-18 21:18:52 -05:00
526160721a feat(brand): emblem behind hero text + Powered By footer with logo
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
2026-07-18 16:56:22 -05:00
352ae4f256 feat(brand): default favicon emblem + Powered By footer badge
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
2026-07-18 16:40:09 -05:00
7a08546da6 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.
2026-07-18 02:20:04 -05:00
3ef1c8e438 feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s
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>
2026-07-17 21:08:59 -05:00
1629796235 feat(houses): tier house visibility — public IDOC-only, staff full, player own
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>
2026-07-17 16:50:37 -05:00
a165c90c62 fix(schema): remove semicolons from shard_governor_terms inline comments
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>
2026-07-17 16:25:14 -05:00
2976d5982f feat(provisioning): provisioning UI — signup, invites, accept page, unlink
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>
2026-07-17 16:06:01 -05:00
91c206bf76 feat(provisioning): game-account signup, admin email invites, unlink (2.0)
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>
2026-07-17 15:50:49 -05:00
55a3adea99 feat(news): auto-push published news to the in-game Town Cryer News gump (2.1)
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>
2026-07-17 15:40:47 -05:00
2957708bab feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses
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>
2026-07-17 12:45:15 -05:00
080478c4a1 feat(shard): ingest Protocol 2.0 boards — guilds, governors, presence, houses
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>
2026-07-17 12:28:54 -05:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
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
2026-07-14 13:16:10 -05:00
ba4d758eab feat(admin): view a user's shard footprint at /admin/users/:id
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
2026-07-12 09:36:43 -05:00
986a8d5d86 News post → town crier + Discord announcement pipeline
Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:

  • town crier — sidecar POST /towncrier via uoLinkClient (stable id
    `post-<id>` so a retry replaces rather than duplicates)
  • discord    — bot POST /internal/announce via botInternalClient
    (single source of truth for the #news channel stays in the bot)

An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.

Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.

Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 16:25:25 -05:00
a2590812e0 Give the homepage teaser a rich text editor
Replace the plain textarea for the homepage_teaser setting with the shared
TipTap rich-text editor, and render the teaser as sanitized HTML in the
portal hero's default layout.

- SettingsAdmin: teaser field now uses RichTextEditor (lazy-loaded, code-split
  like PostEditor); rich fields render in a <div> wrapper instead of <label>.
- HeroElement: text-block lines flagged `html` render sanitized HTML.
- heroLayout: the default-layout teaser line is now an HTML line.
- admin.controller: sanitize homepage_teaser against the body allowlist on save.
- theme.css: collapse the teaser's nested block margins in the hero.

Closes #48

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:06:58 -05:00
c4245e3f6a Restrict public presence to staff + let admins view any character
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
2026-07-11 09:15:18 -05:00
49d0c1bd11 Add shard activity feed + admin live feed; fix public-feed leak
Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.

- lib/shardEvents.js: shared describe()/category/label for every event kind
  (sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
  filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
  Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
  the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
  useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.

Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 03:10:03 -05:00
74d2ead958 Let staff link their own characters + share the game-accounts UI
- 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
2026-07-11 03:05:43 -05:00
fe6f93481b Add player portal + character-sheet front end (phase 4 follow-up)
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
2026-07-11 02:51:50 -05:00
e7bc316863 Add admin shard control: config, status, town crier (phase 5)
- admin/uoLink.controller.js: GET /admin/uo-link/config (masked config + live
  health + ingestion stats from the socket/broadcaster); PUT to save base/ws
  URL + write-only token + protocol + enabled, which (re)starts or stops the WS
  ingest client and activity-logs the change; POST/DELETE /uo-link/towncrier to
  publish/remove town-crier messages; GET /uo-link/stream (admin SSE channel,
  full feed incl. audit/cheat). Mounted adminOnly with express-validator guards
  + #swagger annotations (new "Admin · Shard" tag, TownCrierRequest schema).
- server.js: startup probe (checkUoLink) that logs reachability and warns
  loudly on a protocol mismatch when the integration is enabled.
- client: api.admin uo-link methods; ShardAdmin.jsx control panel (status
  panel with ingestion stats, config form, town crier) modeled on
  DiscordBotAdmin; wired into AdminLayout nav/titles + the /admin/shard route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:22:17 -05:00
064f02c4b6 Add player account linking + roster/vendor reads (phase 3)
Ties an in-game account to a website user and gates reads on ownership.

- schema: shard_account_links (account PK → user_id, char_name, linked_at;
  FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
  authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
  - POST /player/shard/link — confirm a one-time [link code via
    uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
    activity.log it; bad/expired codes → 400, shard down → 503.
  - GET /player/shard/accounts — the caller's linked accounts.
  - GET /player/shard/roster/:account and /vendors/:account — live round-trips,
    ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
  express-validator guards + #swagger annotations; new "Player · Shard" tag and
  ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:13:46 -05:00
523113f013 Add public shard read endpoints + live SSE stream (phase 2)
Curated, same-origin, token-free reads so the browser never sees the sidecar
URL or token:

- public/shard.controller.js:
  - GET /public/shard/status — connection state + online count + latest economy
    (from the site's ingested data).
  - GET /public/shard/feed?kind=&limit= — recent notable events from the log.
  - GET /public/shard/economy — gold-supply series (oldest → newest).
  - GET /public/shard/idoc — houses currently at IDOC.
  - GET /public/shard/char/:serial — live sheet round-trip via uoLinkClient,
    briefly cached; 503 (shard restarting) serves a stale cache or a retry
    banner rather than an error.
  - GET /public/shard/stream — public SSE channel (safe kinds only).
- Wired into public.routes.js with express-validator guards and #swagger
  annotations; new "Public · Shard" tag + ShardStatus/ShardEvent/
  ShardEconomyPoint/ShardHouse schemas; swagger-output.json regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:11:28 -05:00
9d9f5aac28 Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)
The site now ingests the sidecar's live WebSocket feed and persists it to its
own MariaDB, and re-broadcasts curated events to browsers over SSE.

- schema: shard_events (append-only notable-kind log, sha1 dedupe_key +
  INSERT IGNORE for idempotent reconnect backfill), shard_online (current
  players, upsert/refresh/remove), shard_economy (gold-supply series),
  shard_houses (per-house decay stage + derived is_idoc).
- model/shardEvents + model/shardState: the .db.js/.model.js split; writes
  take camelCase event data, reads are shaped; online upsert uses COALESCE so
  a partial char.vitals refresh never blanks login fields.
- utils/shardIngest: single dispatcher routing each kind to state writes
  and/or the event log, then the broadcaster. High-frequency kinds
  (char.vitals, economy.supply) update state only. A changed server.hello
  bootId clears the stale online roster. Deps are injected for unit testing.
- utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies
  the ws.hello protocol, backfills via /history + /economy on every
  (re)connect (dedupe handles overlap), reconnects with capped backoff, and
  mirrors connection state into uo_link_config. Self-guards: only connects when
  the integration is enabled with a token.
- utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin
  (all) channels, keepalive pings, per-client cleanup.
- server.js: start the ingest socket on boot (no-op until configured) and stop
  it + close SSE streams on graceful shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:08:56 -05:00
ab647756f0 Add uo-link sidecar foundation: config store + REST client (phase 0)
Introduces the DB-backed connection config for the uo-link sidecar (the
HTTP + WebSocket bridge to the ServUO shard) and a never-throw REST client,
mirroring the existing Discord-bot integration:

- uo_link_config singleton table (base/ws URL, AES-256-GCM-encrypted shared
  token, protocol pin, enabled, and last-known status/plugin_connected/
  last_event_at/boot_id mirrors for the admin panel).
- model/uoLinkConfig: getSafe (never returns the token — only hasToken),
  getWithToken (server-side decrypt), save (blank token = unchanged),
  recordStatus (mirror the sidecar's reported state).
- utils/uoLinkClient: never-throw fetch client returning {ok,data,status,
  error}; Bearer token + X-UOLink-Version on every call; brief config cache;
  helpers for health/char/roster/vendors/history/economy/link/towncrier.
- .env.example: UOLINK_BASE_URL/WS_URL/PROTOCOL defaults (token stays
  admin-managed in the DB, never an env var).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:02:40 -05:00
e7f5f24809 Regenerate Swagger with the CMS pages endpoints
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:57:39 -05:00