d765280e283f1e195ba312631a5a3a220f54bb0a
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8771a1cf6c |
feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.
Three things the pages have to say out loud, all consequences of how the data is
gathered:
- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
a full cycle behind. The banner is driven by the OLDEST vendor row, not the
newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
the item id, never an invented label.
## The pre-wired visibility rules, re-checked
Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:
- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
would have made the rule match nothing — the same failure, one part later. It
is nested on the wire and on the read model so one rule hides the facet, the
coordinates, the region and the house together; five flat keys would be five
rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
serial that the leaderboards and guild boards resolve back to that same name
has not hidden anything.
Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.
## Notable
- **No payload column on shard_vendors**, unlike shard_points_boards next door.
The board's top-N is a fixed-size list read whole; here the items ARE the
searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
cliloc — a player set it, so it is more specific). Resolving at query time
would put the cliloc table on the hot path and make search-by-name impossible.
Because the shard's diff sweep will not re-send an unchanged shop just because
the site learned what its items are called, a cliloc import now triggers a bulk
re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
shop re-published identically is still freshly confirmed — without this the
staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
stopping on a short page as well as on `total`, so a concurrent sweep shrinking
the index cannot spin the walk.
## How it was tested
673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.
Verified full-stack against the live MariaDB and a real shard, not only units:
- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
sidecar, into the site — names resolving through the cliloc table ("longsword",
"katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
shopName and price survive; audience=player 403s; enabled=0 404s; and
/shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).
Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 9b74999610 |
feat(security): soak the tightened CSP on report-only, with a same-origin sink
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>
|
|||
| 12d50fd615 |
chore(quality): resolve SonarQube code smells across website
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> |
|||
| 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> |
|||
| 10aed49bb6 |
feat(auth): self-service password reset (backend + web)
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 |
|||
| f8bcc7f6a3 |
Player accounts backend: schema, registration, self-service, SSO provision
- Widen users.role enum to include 'player'; make password_hash nullable; add email/email_verified/status/last_login_ip; pin username _ci collation. - POST /auth/register (honeypot + registerLimiter + botScore, reserved-name blocklist, duplicate->409, auto-login). player_registration setting gates it. - SSO auto-provision in finishLogin (setting-gated); return/portal-aware SSO redirects for the player portal; status refusal on login + requireAuth. - New /player self-service group (account, change username/password, TOTP, identities), reusing account.controller; accountChangeLimiter. - Admin: 'player' role + status/email on user create/update, role/status audit, player_registration enum validation, derived public registration flags. - usernamePolicy module (reserved, sanitize, derive, dedup) + unit tests; extend SSO callback tests. 133 server tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV |
|||
| 7a21cc636c |
Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely through a new admin-only Discord Bot panel — token stored encrypted in the DB and pushed to the bot process in-memory, never an env var. Built in phases, each independently verified against a live Discord guild: - Bot skeleton: gateway connection, internal shared-secret API, self-heals on its own restart by pulling config from the site - Moderation core: /ban /kick /mute /warn /warnings + mod-log channel - Word/invite/spam filtering with leetspeak-resistant normalization and a staff role/channel allowlist - Scheduled messages: recurring (cron) and one-off channel posts - Role assignment: button role menus, auto-role on join, temp roles, bulk role ops - Auto-rotating primary invite with an audit log - Site integration: news-publish -> Discord announce webhook, manual /announce, read-only /wiki search Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb driver defaulted to timezone 'local', silently mis-serializing bound Date params by the host's local offset instead of the DB's UTC session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 31b31c3a17 |
Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|||
| 870971fc12 |
Add Bot Activity admin panel: banned-IP view + recent events + emergency unban
Expose the botScore middleware's in-memory scoring/ban state to admins. Previously state lived only in the store Map with no persistence or API — the only visibility was tailing container logs. - botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and ban events (most-recent-first); listState() snapshot of all scored IPs; unban() to clear a single IP. - New admin-only endpoints GET /admin/bot-activity and POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is activity-logged with the admin username. - Bot Activity tab: currently-banned table with Unban, plus a recent-events feed, following the existing admin table patterns. - Tests for the buffer, listState, and unban (guard lets an unbanned IP back through). README updated. Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is in-memory, matching the store; not persisted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|||
| d38c98ad9e |
Harden admin login: RBAC-safe controls, 2FA, bot-scoring, rate limits (#9)
Adds a layered set of protections around the admin login and the app edge.
Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
rejected (coerced to 1) to prevent XFF spoofing that would dodge every
IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
proxy IP without a redeploy. Documents the Omada static-reservation
assumption.
Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
separate per-IP exponential backoff that persists across the rate window.
All failures return one generic message (no user/pass disclosure).
Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
generically and is scored as an unambiguous bot.
Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
signed challenge (stage:'totp', not a session) is required before the
real session is issued.
Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
unconditionally — independent of score/ban state, so a scanner rotating
through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
score with quiet-period decay temp-bans an IP from ALL routes once past a
(deliberately low) threshold, to protect /admin from credential stuffing.
Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
store can't grow unbounded; the interval is unref'd and cleared on
graceful shutdown.
Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|||
| eef79e2403 |
Initial commit: UOMysticmoon backend (Express + MariaDB + JWT)
- Layered API (router -> controller -> model -> db), serverlinkr pattern - Public / auth / admin route groups; posts, wiki, settings, users, activity models - JWT httpOnly-cookie auth (Secure auto-detected: LAN HTTP + Pangolin HTTPS) - Site LIVE/MAINTENANCE mode with admin preview bypass - Dual file+console logging (info/warn/error/debug) + HTTP access logs - Docker Compose (app + MariaDB), schema.sql + seed, .env.example - Verified end-to-end against MariaDB (27/27 smoke checks) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |