Files
docs/website/BACKEND_DESIGN.md
wtclaude be9f5019fa docs(link): the cliloc table, and why §8.6's recommendation was not implementable
Protocol 3.0 §8.6 resolved as its own website-only change, landing ahead of
§8 so the marketplace ships with real item names. Matching documentation for
website #TBD.

NEW website/CLILOCS.md — operator-facing: why the conversion step exists, how
to convert, how to configure the path, the refresh contract, what gets stored
and how names are applied.

link/v3.md §8.6 rewritten. Two things in the original recommendation turned
out to be wrong, and both are recorded because the reasoning generalises:

1. The committed db/data/clilocs.json artifact predates the Part C
   corrections (§6.1) and violates both — no committed snapshot of derived
   content, and nothing EA-derived ever shipped. UO's strings are EA's,
   exactly as the creature sprites are.
2. "scripts/buildClilocs.js reads the UO client's Cliloc.enu" is not
   possible. EVERY current client ships its cliloc files compressed (first
   DWORD's high byte 0x8E, the Mythic container); the plain layout is what
   those files looked like before that change, and parsing one as the other
   does not fail cleanly — it yields ~19k records with negative ids, 1,722
   distinct keys out of 19,508, one 62 KB "string", and a truncation
   somewhere in the middle. ServUO's own Ultima.StringList cannot read it
   either, so VendorSearch.GetItemName is already inert on such a shard and
   the work could not be pushed to the plugin.

That second point also retires an open question in §8.2: the warning never to
call GetItemName in the market sweep costs us nothing we could otherwise have
had, because the in-game Vendor Search gump has the same gap.

Three traps found by building it are recorded: StringList.SaveStringList
RE-COMPRESSES on save (its output is byte-identical to its compressed input,
because its purpose is round-tripping a file back into the client); trimming a
text line before splitting silently drops the ~half of a table that is empty
strings; and Number('') is 0, not NaN.

Also updated:
- Progress and §9 sequencing tables: order 5 split into 5a (this, website
  only) and 5b (the four-repo wire change).
- website/BACKEND_DESIGN.md — shard_clilocs / shard_cliloc_meta, the three
  admin routes, and why there is no staged-approval flow and no public route.
- link/INTEGRATION.md — the char.profile field note now says explicitly not to
  expect the shard to resolve clilocs, and points at CLILOCS.md.
- §10 documentation obligations list CLILOCS.md.

Documentation only. Every claim was written after the corresponding behaviour
was observed running: the compressed-format finding and the parse failures
come from the real client files on this machine, and the counts (123,490
parsed → 67,496 stored) and timings from importing them into the live MariaDB.

PROJECT_TREE.md files are deliberately untouched — they are CI-generated by
the sync-project-tree workflow and say so in their header.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:22:05 -05:00

79 KiB

UOMysticmoon Website — Backend Design

Phase 1 of 3: backend design → Claude Design (frontend mockup) → coding. This document is the contract the later phases build against.

Public contact email: UOMysticmoon@gmail.com


1. Stack & top-level decisions

Concern Decision Rationale
Runtime Node.js + Express serverlinkr pattern
Database MariaDB (own container) spec; mariadb pool, parameterized SQL, no ORM (keeps the lightweight model/db split from serverlinkr)
Auth JWT in an httpOnly cookie spec says "JWT auth" + "secure cookies when HTTPS"; httpOnly keeps the token out of JS (XSS-safe), SameSite=Strict covers CSRF for a same-origin admin panel
Frontend React + Vite, same repo, served by Express in prod spec
Hashing bcrypt (bcryptjs) spec; matches serverlinkr
Deploy Docker Compose (app + db) behind Pangolin spec

Adapting serverlinkr → this project

  • *.mongo.js (mongoose) → *.db.js (MariaDB queries), exactly as the spec names them.
  • Drop the session/passport hybrid (express-session, passport, passport-local, connect-mongo). Pure stateless JWT instead — simpler and matches "JWT auth".
  • Routes grouped by access level (auth / public / admin) per spec, instead of serverlinkr's per-entity routers. Models stay grouped by entity.

2. Folder structure

Skeleton from the spec, with a small number of justified additions marked (+).

Complete. The monolithic route files (admin.routes.js especially, originally 1552 lines / 110 routes) have been split into one router file per business capability — in place, with every URL unchanged. See API_V2_PLAN.md § Phase 2.

users, account, invites, auth/providers (PR 1, 28 routes), moderation, bot-activity, activity (PR 2, 18 routes), posts, uploads, wiki, pages (PR 3, 31 routes) and shard, uo-link, email, discord-bot, settings, dashboard/site-mode (PR 4, 33 routes) each live in their own router under admin/, behind admin/index.js. PR 5 did the same for public/ (24), player/ (20) and the residual auth/ (10). admin.routes.js, public.routes.js, player.routes.js and auth.routes.js are all deleted; each group is now a directory whose index.js owns the group gate and the mount table and declares no routes of its own.

"Every URL unchanged" is enforced mechanically, not by review: server/scripts/routeManifest.js (npm run routes:manifest) walks the live Express stack and writes the sorted { method, path } freeze to server/routes.manifest.json, mirrored here as api-route-inventory.json. PR checks regenerate it and fail on any diff, so a split PR that moves a URL cannot merge silently. See § 4.0.

server/
  .env.example
  package.json
  db/
    schema.sql                  (+) DDL, also auto-run by the MariaDB container
    seed.js                     (+) seed wiki pages, default settings, first admin
  src/
    server.js                   bootstrap: ensure schema, then listen on 0.0.0.0
    app.js                      express app + middleware wiring
    router/
      api.router.js             mounts /v1
      v1/
        v1.router.js            mounts /auth /public /admin /player
        auth/    index.js          mounts the routers below; no group gate — /auth
                                   is where an anonymous caller becomes
                                   authenticated, so the authenticated parts gate
                                   themselves. Mount order is load-bearing (see
                                   session.router.js)
                 login.router.js        (2)  /auth/login + /login/totp — shared
                                             loginGuards stack
                 register.router.js     (1)  /auth/register — honours the
                                             player_registration setting
                 invite.router.js       (2)  /auth/invite/:token[/accept] — the
                                             token is its own authority, so it
                                             bypasses player_registration
                 password.router.js     (3)  /auth/password/forgot + reset/:token
                 session.router.js      (2)  POST /logout and GET /me — the two
                                             singletons owning no path segment, so
                                             mounted at the group root, LAST: the
                                             /me sub-routers below also match the
                                             bare /me and supply its noindex header
                 me.routes.js          (23)  /auth/me/account*, sessions, trusted
                                             devices — router-level requireAuth
                 notifications.routes.js (3) /auth/me/devices*, notifications/*
                 mobile.routes.js +          /auth/mobile/* — native bearer login
                 mobileSso.routes.js    (5)
                 sso.routes.js          (4)  mounted PATHLESS: owns two prefixes,
                                             /auth/providers and /auth/sso/*
                 loginGuards.js              shared backoff/slow/limiter stack for
                                             every credential-guessing surface
                                             (not a router)
                 auth.controller.js + invite/passwordReset/sso/mobile controllers
        public/  index.js          mounts the routers below; **no group gate** —
                                   this surface is anonymous by design (SPA
                                   logged-out, Discord bot, Android ShardStream)
                 posts.router.js        (2)  /public/posts/:category[/:idOrSlug]
                 wiki.router.js         (4)  /public/wiki — /categories and /tags
                                             MUST precede /:slug
                 pages.router.js        (2)  /public/pages — the draft-preview
                                             route precedes /:slug and is
                                             deliberately not site-mode gated
                 shard.router.js       (14)  /public/shard/* incl. the anonymous
                                             SSE stream; never site-mode gated
                 atlas.router.js        (6)  /public/atlas/* — the spawn atlas.
                                             NOT under /shard: nothing here
                                             touches the sidecar, and unlike
                                             /shard it IS site-mode gated
                 site.router.js         (4)  /settings /status /version /contact —
                                             the group-root singletons; declares no
                                             router-level middleware
                 public.controller.js + shard.controller.js
        player/  index.js          owns the shared `noindex, requireAuth` gate
                                   (authenticated, ANY role — staff are a superset
                                   of players) and the mount table
                 account.router.js      (8)  /player/account — credentials, TOTP,
                                             linked identities; handlers shared
                                             with /admin/account and /auth/me
                 shard.router.js        (8)  /player/shard — linking + own roster,
                                             vendors, chars, sales, houses
                 appeals.router.js      (4)  /player/appeals
                 shard.controller.js + appeals.controller.js
        admin/   index.js          mounts the capability routers below at their
                                   own prefixes; owns the shared
                                   `noindex, isLoggedIn, staffOnly` gate and
                                   declares no routes itself
                 account.router.js       (6)  /admin/account  — self-service, no adminOnly
                 users.router.js        (15)  /admin/users    — adminOnly
                 invites.router.js       (3)  /admin/invites  — adminOnly
                 authProviders.router.js (4)  /admin/auth     — adminOnly
                 moderation.router.js   (15)  /admin/moderation    — modAccess
                                              (admin+moderator) at router level
                 botActivity.router.js   (2)  /admin/bot-activity  — adminOnly
                 activity.router.js      (1)  /admin/activity      — staff-wide
                                              audit log, no extra gate
                 posts.router.js         (9)  /admin/posts    — editor tier, no
                                              gate beyond staffOnly
                 uploads.router.js       (1)  /admin/uploads  — rich-text editor
                                              image upload
                 wiki.router.js         (14)  /admin/wiki     — pages, revisions,
                                              categories, tags
                 pages.router.js         (7)  /admin/pages    — CMS page builder
                 imageUpload.js               shared multer config for the two
                                              upload routes above (not a router)
                 shard.router.js        (16)  /admin/shard    — 7 self-service
                                              account-linking routes (no extra
                                              gate, handlers shared with
                                              /player/shard) + 9 in-game staff
                                              ops on modAccess, per route
                 uoLink.router.js        (5)  /admin/uo-link  — sidecar config,
                                              town crier, admin SSE — adminOnly
                 email.router.js         (6)  /admin/email    — Gmail OAuth2
                                              delivery — adminOnly
                 discordBot.router.js    (2)  /admin/discord-bot — adminOnly
                 settings.router.js      (2)  /admin/settings — adminOnly
                 dashboard.router.js     (2)  GET /dashboard (staff-wide) and
                                              PUT /site-mode (adminOnly) — the
                                              two singletons owning no path
                                              segment, so mounted at the group
                                              root; declares no router-level
                                              middleware, which is what makes a
                                              root mount safe
                 admin.controller.js + the per-capability controllers
                                   (already domain-split; the split PRs re-wire
                                   routes, not logic)
    model/
      users/     users.model.js    + users.db.js
      posts/     posts.model.js     + posts.db.js     (news/five-on-friday/newsletter/screenshots)
      wiki/      wiki.model.js      + wiki.db.js
      settings/  settings.model.js  + settings.db.js
      activity/  activity.model.js  + activity.db.js  (+) admin activity log
    middleware/                 (+)
      siteMode.js               LIVE/MAINTENANCE gate for public content
      noindex.js                X-Robots-Tag: noindex,nofollow on admin
      rateLimit.js              login limiter
      validate.js               express-validator error handler
    utils/
      auth.js                   JWT sign/verify, isLoggedIn middleware
      db.js                     MariaDB pool + ensureSchema()
      mailer.js                 (+) nodemailer; mailto fallback if SMTP unset
client/                         built in Phase 2/3 (React + Vite)
Dockerfile
docker-compose.yml
.env.example
.gitignore

Why the additions: the spec's feature list requires an activity log, a maintenance-mode gate, login rate limiting, admin noindex, and SMTP email — none fit cleanly in the four listed models/two utils. They're isolated in middleware/ + one activity model + utils/mailer.js, and the spec explicitly says the layout is "expandable."


3. Database schema (MariaDB)

utf8mb4 throughout. Created idempotently on boot (ensureSchema()) and shipped as db/schema.sql for the container's /docker-entrypoint-initdb.d.

users

col type notes
id INT PK AUTO_INCREMENT
username VARCHAR(32) UNIQUE NOT NULL
password_hash VARCHAR(72) NOT NULL bcrypt; never returned by the API
role ENUM('admin','editor') NOT NULL DEFAULT 'admin' room to grow
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
last_login_at DATETIME NULL shown in user management

posts — one table, four categories

col type notes
id INT PK AUTO_INCREMENT
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL
title VARCHAR(200) NOT NULL
slug VARCHAR(220) NULL optional clean URL
excerpt VARCHAR(400) NULL list teaser
body MEDIUMTEXT NULL markdown/HTML; main text for news/5oF/newsletter
image_url VARCHAR(500) NULL required for screenshot, optional hero elsewhere
published TINYINT(1) NOT NULL DEFAULT 0 publish/unpublish toggle
author_id INT NULL FK→users(id) ON DELETE SET NULL
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
published_at DATETIME NULL set when first published; list order

Index: (category, published, published_at DESC).

wiki_pages

col type notes
id INT PK AUTO_INCREMENT
slug VARCHAR(120) UNIQUE NOT NULL e.g. new-player-guide
title VARCHAR(200) NOT NULL
body MEDIUMTEXT NULL markdown/HTML
updated_by INT NULL FK→users(id)
created_at / updated_at DATETIME

Seeded with the 8 spec categories: new-player-guide, maps-atlas, systems, items, monsters, crafting, lore, rules.

settings — key/value, expandable

col type notes
key VARCHAR(64) PK
value TEXT NULL
updated_by INT NULL FK→users(id)
updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP

Seeded keys: site_mode (default maintenance), site_mode_changed_at, site_mode_changed_by, maintenance_message, status_message, homepage_teaser, contact_email (=UOMysticmoon@gmail.com), site_title.

activity_log — append-only

col type notes
id INT PK AUTO_INCREMENT
user_id INT NULL FK→users(id)
action VARCHAR(64) NOT NULL e.g. auth.login, site_mode.change, post.create
detail TEXT NULL JSON string of what changed
ip VARCHAR(45) NULL from req.ip (needs trust proxy)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
col type notes
id INT PK AUTO_INCREMENT
token_hash CHAR(64) UNIQUE NOT NULL sha256 hex of the opaque token; plaintext never stored
user_id INT NOT NULL FK→users(id) ON DELETE CASCADE the account this reset targets
status ENUM('pending','used') DEFAULT 'pending' single-use (atomic markUsed)
requested_ip VARCHAR(64) NULL who asked (audit only)
expires_at DATETIME NOT NULL ~1h TTL, enforced in the model on top of this
created_at / used_at DATETIME

Same "store only the hash of an opaque token" pattern as user_invites / mobile_refresh_tokens. A DB read never yields a usable reset link. See §4 /auth/password/*.

push_devices — opt-in push endpoints (M7)

col type notes
id INT PK AUTO_INCREMENT
user_id INT NOT NULL FK→users(id) ON DELETE CASCADE owner
transport ENUM('unifiedpush','fcm') DEFAULT 'unifiedpush' UnifiedPush for the sideloaded APK; FCM reserved for a later Play flavor
endpoint VARCHAR(512) NOT NULL the distributor URL the app's ntfy topic was handed (or an FCM token). Unguessable but not a secret — stored in the clear (unlike refresh tokens), because pushes are content-free tickles
platform VARCHAR(40) NULL free-form label, e.g. android
created_at / last_seen_at DATETIME

UNIQUE(user_id, endpoint) — re-registering the same endpoint is an idempotent upsert.

notification_subscriptions — which streams a user opted into (M7)

col type notes
user_id INT NOT NULL FK→users(id) ON DELETE CASCADE
stream_id VARCHAR(64) NOT NULL an id from the catalog (config/notificationStreams.js), validated on write
created_at DATETIME

PRIMARY KEY(user_id, stream_id). Subscriptions are per-user (applied to every device); a PUT replaces the whole set. Nothing is pushed unless the user subscribed.

mobile_auth_sessions / mobile_auth_codes — mobile SSO bridge (M9)

Two short-lived, self-pruning tables that bridge a browser SSO redirect flow to a native client. They carry the app ↔ website PKCE + CSRF state (a second PKCE layer, distinct from the website ↔ IdP PKCE the sso_tx cookie already carries) and the one-time authorization code the app exchanges for bearer tokens. Neither holds a secret in the clear — the PKCE code_challenge is a hash by construction, and the authorization code is stored as a sha256 hash only (same pattern as user_invites / password_resets / mobile_refresh_tokens).

mobile_auth_sessions — one row per /auth/mobile/sso/start:

col type notes
id INT PK AUTO_INCREMENT
session_id CHAR(36) UNIQUE opaque uuid; carried inside the signed sso_tx (mode mobile) so the callback can find this row
provider VARCHAR(40) NOT NULL provider id validated enabled at /start
code_challenge VARCHAR(255) NOT NULL app-supplied PKCE S256 challenge (base64url); verified at /exchange
redirect_uri VARCHAR(255) NOT NULL the requested app callback — exact-match against the allowlist (never prefix)
state VARCHAR(255) NOT NULL app-generated opaque CSRF value, echoed on the callback for the app to verify
status ENUM('pending','completed','consumed') DEFAULT 'pending' pendingcompleted when the code is minted; consumed after a successful exchange
user_id INT NULL FK→users(id) ON DELETE CASCADE set once SSO resolves the account
trust_device TINYINT(1) NOT NULL DEFAULT 0 user ticked "trust this device" on the Custom Tab TOTP form. A boolean only — it tells /exchange to mint the app's own trust token; the token never rests here (only its sha256 reaches trusted_devices)
expires_at DATETIME NOT NULL short (~10 min — one redirect round-trip incl. TOTP)
created_at / used_at DATETIME used_at stamped at exchange

mobile_auth_codes — one row per completed SSO callback (the code the app redeems):

col type notes
id INT PK AUTO_INCREMENT
code_hash CHAR(64) UNIQUE sha256 hex of the opaque ≥128-bit code; the raw code never touches the DB
user_id INT NOT NULL FK→users(id) ON DELETE CASCADE the authenticated account
session_id CHAR(36) NOT NULL the owning mobile_auth_sessions.session_id (ties the code to its PKCE challenge)
expires_at DATETIME NOT NULL very short (~5 min)
used_at DATETIME NULL set on first successful exchange — single use (a reused code fails)
created_at DATETIME

Both self-prune (indexed expires_at): a best-effort sweep runs at boot beside the existing revoked_sessions prune, and each bridge write opportunistically deletes expired rows — so no cron infra is added (same approach as revoked_sessions).

mobile_refresh_tokens additions (M9). Two nullable columns are added to support the device list/revoke surface: device_name VARCHAR(100) NULL (a friendly label) and last_used_at DATETIME NULL (bumped on each refresh). Existing rows get them via the schema's ALTER section; the token model is otherwise unchanged.

trusted_devices — MFA "Trust this device"

Lets a browser/app skip the TOTP step at login (never the password) for 30 days. Pattern-identical to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust httpOnly cookie on web, X-Trust-Token / EncryptedSharedPreferences on native) and only its sha256 hash is stored (token_hash CHAR(64) UNIQUE) — sha256, not bcrypt, because a 256-bit random token is looked up by its hash via the unique index (a per-row salt would break that). Columns mirror the mobile table (platform, device_name, device_hash, user_agent, created_at, last_used_at, expires_at, revoked_at). Capped at 10 rows/user in application code — no silent pruning (an over-cap trust is refused so the client can prompt the user to revoke one first). Consulted only at the login/password step, never at token refresh, and revoked wholesale on untrust / password change / password reset / TOTP disable. See docs/website/TRUSTED_DEVICES_MFA.md.

recovery_codes — single-use MFA backup codes

Generated at TOTP enrollment (10 at a time, shown to the user once) so a user who loses their authenticator can complete login without an admin reset. code_hash VARCHAR(72) is a bcrypt hash (not sha256): a recovery code is a human-typed, lower-entropy fallback credential — the closest analogue to a password — and there is no hash-lookup constraint (verification fetches the user's ≤10 unused rows and bcrypt.compares each, like password verification). used_at is the single-use marker. Cleared wholesale on TOTP disable / password change / password reset.

shard_ruleset — the shard's published ruleset (Protocol 3.0)

Singleton row (id = 1, CHECK-constrained) holding the latest world.ruleset frame: rev, expansion, payload JSON (the whole frame), t, updated_at. The shard re-emits the complete ruleset on every sidecar connect, so this is an overwrite, not an append — and the kind is deliberately not in LOGGED_KINDS, since logging it would put a duplicate row in shard_events on every reconnect while server.hello already marks each of those.

The frame is stored whole rather than normalized into columns: it is a flat description of server config that is read as one page, so splitting it up would mean a schema change every time the shard grows a new block. rev (the shard's FNV-1a of the body) and expansion are hoisted only because they are cheap to display — the same payload-plus-hoisted-columns shape shard_champs uses.

No row means the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false), served as null rather than {}: "not published yet" and "published, everything off" are different answers and the page renders them differently.

shard_points_boards — points / loyalty leaderboards (Protocol 3.0)

One row per point system, keyed by the shard's own PointsType name (QueensLoyalty, CleanUpBritannia, …). The shard carries ~25 of these, each a standing players build over months. Columns: system (PK), name, name_cliloc, max_points, players, show_on_gump, payload JSON (the whole points.board frame), t, updated_at.

The top-N list stays inside payload rather than being normalized into a shard_points_entries table. It is a fixed-size list (10 by default) that is only ever read whole — exactly like shard_governors.candidates — so normalizing buys nothing until something needs a per-character reverse lookup, and a character's own standings already ride inside char.profile instead.

Board state, not events: points.board is not in LOGGED_KINDS, for the same reason guild.update isn't. The shard emits a frame every time anyone's score moves a top ten, so logging would grow shard_events without bound for something whose only interesting value is its latest version. There is also no delete path — the shard's set of systems is fixed at startup, so there is no points.remove to mirror.

Two values carry non-obvious meanings, both set by the plugin and both documented in link/INTEGRATION.md §4:

  • max_points = 0 means uncapped, and on a real shard that is the common case (ServUO's uncapped idiom is double.MaxValue, which the plugin normalises to 0). Anything rendering points / max_points must special-case it.
  • name is usually NULL, with name_cliloc set instead — most systems name themselves with a cliloc rather than a literal. Listing therefore orders by COALESCE(name, system), so boards awaiting cliloc resolution sort by their own key rather than clumping together under NULL.

shard_feature_visibility — per-feature audience config (Protocol 3.0)

One row per shard feature: feature (PK), enabled, audience (a rung on the ladder in §6.5), stream (whether the feature's kinds fan out over SSE at all), field_rules JSON ({field: rung} for the sensitive fields only), updated_by, updated_at.

An absent row means "use the compiled default", and the compiled defaults reproduce pre-3.0 behavior — so an empty table is a no-op and there is nothing to seed. Stored rows are merged over the defaults on read, which is also where the invariants are re-applied: a row naming an unknown feature is ignored (a stale row must not resurrect a removed feature), an invalid rung falls back to the default rather than failing open, and a rule touching a locked field (acct / webId) is discarded. See §6.5.

shard_spawn_* / shard_regions / shard_landmarks / shard_champion_spawns / shard_atlas_meta — the spawn atlas (Protocol 3.0)

Static shard content, not live shard state. Nothing here comes from the sidecar: the atlas is derived from the shard's own ServUO tree, re-read on every server boot and hash-gated so an unchanged tree costs one read pass and no write. Nothing is precomputed and committed — a shard's maps change over its life, and a snapshot in the repo would silently drift from the world players actually see. These tables stay populated whether the shard is up or not. Full operator detail in SPAWN_ATLAS.md; the design is docs/link/v3.md §6.

No facet name appears anywhere in the code. A shard may add facets, replace them, or rename them when its maps are updated; the facet set is discovered from the tree, and the loose spellings in Data/Locations are matched against it rather than looked up in a table.

Table Key columns
shard_spawn_creatures slug PK, name, total, points, facets JSON, art NULL
shard_spawn_points id PK, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, tod_start/end/mode, region, landmark, label
shard_spawn_point_types (point_id, slug) PK, max_count
shard_regions facet, name, type, priority, parent, rects JSON
shard_landmarks facet, name, grp, x, y, z
shard_champion_spawns slug PK, name, grp, type, random_type, facet, x, y, z, radius, label
shard_atlas_meta Singleton (id = 1), payload JSON (counts, a sha256 per source file, parserVersion), imported_at
shard_atlas_pending Singleton (id = 1), status (pending/rejected), payload JSON, detected_at

The first seven are import-owned: a refresh empties and reloads every one inside a single transaction, so a failed reload leaves the previous atlas intact rather than a half-loaded world. Nothing else writes to them, and nothing holds a foreign key to them — no FKs at all, consistent with every other shard_* table.

shard_atlas_pending is the security-relevant one. A refresh that would REMOVE a facet is never applied automatically: facet loss is indistinguishable at boot from a half-copied or mid-update tree, so it is staged here for an admin to approve or reject, and startup is never blocked by it. Only the decision is stored — source hashes plus the facet diff, a few KB — and approving re-parses the tree, so a multi-megabyte blob never lands in the database and what gets applied matches the tree at approval time. A rejection is remembered against those exact hashes so a declined refresh does not re-prompt on every restart. Everything else (new facets, renamed regions, changed spawns) applies immediately, since none of it can destroy data an operator would miss.

The boot refresh is best-effort by contract: no configured path, an unreadable mount, a malformed file or a database error is caught and logged, and the site comes up serving whatever atlas it had. The tree path comes from the spawn_atlas_servuo_path setting, falling back to SERVUO_PATH.

A refresh re-derives when the tree changed OR the parser did. spawnAtlasSource.PARSER_VERSION is stored in shard_atlas_meta beside the source hashes and bumped whenever the parser produces different data from identical files. Hashing the tree alone would strand an install whose maps never change on whatever an older build derived — a corrected parse would ship and never reach the data.

Four column choices worth stating, because each one is a trap:

  • spawn_range, not range, and grp, not group — both are reserved words.
  • DELETE, not TRUNCATE. TRUNCATE is DDL in MariaDB and implicitly commits, which would defeat the all-or-nothing reload. At ~7k rows the difference does not matter.
  • Point ids are assigned explicitly, not left to AUTO_INCREMENT: the shard_spawn_point_types rows need to know them, and conn.batch() reports no usable insertId for a multi-row insert.
  • Plain INDEX on name, deliberately not FULLTEXT. ~800 creature rows makes a LIKE scan free, and FULLTEXT's minimum token length would break searches for names like "orc".

shard_champion_spawns is the configured altar roster ("there is an Unholy Terror altar in Deceit"). The live champ.update feed in shard_champs is the separate answer to "it is on level 3 right now". Both exist; they are not the same data.

shard_spawn_creatures.art is always NULL on a fresh import. The project ships no creature artwork: sprites live in the operator's own client .mul/.uop files and are theirs, not ours to redistribute. An operator supplies art via a gitignored map plus images under the (already gitignored) server/uploads/atlas/. Text-only is the normal, supported state.

shard_clilocs / shard_cliloc_meta — UO's localization table (Protocol 3.0)

Items on the wire carry a LabelNumber, not a name. The bridge has always sent it — char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per marketplace listing — but with no table to resolve it against, the character sheet could only render id 1023721 where the game renders "quarter staff".

Table Shape
shard_clilocs number INT PK, flag, text TEXT
shard_cliloc_meta Singleton (id = 1), payload JSON (source file, sha256, count, parserVersion), imported_at

Import-owned and all-or-nothing in one transaction, same contract as the atlas — including DELETE, not TRUNCATE, for the same reason.

Sourced from a file the operator converts once from their own UO client, at a path from the cliloc_client_path setting falling back to UO_CLIENT_PATH. Nothing client-derived is committed: UO's strings are EA's, exactly as the creature sprites are. A shard with nothing configured is fully supported — names render as ids. Full design and operator guide: CLILOCS.md.

The conversion step is not avoidable: every current client ships its cliloc files compressed (first DWORD's high byte 0x8E), and ServUO's own bundled Ultima.StringList cannot read that either — so the shard cannot supply names on our behalf. The plain layout and a delimited text export are both accepted, sniffed by header rather than extension.

Three decisions worth stating:

  • text is TEXT, not VARCHAR. Long property descriptions reach 12 KB. The index that matters for marketplace search is the denormalized shard_vendor_items.display_name, not this table.
  • Blank entries are dropped at import — 123,490 parsed → 67,496 stored. Roughly half a cliloc table is empty strings for ids the client reserves and never uses; a row that resolves to no name is indistinguishable from no row at all, and dropping them makes the binary and text imports converge on identical content.
  • No staged-approval flow, unlike the atlas. The atlas escalates facet loss because a half-copied tree and a real map change are indistinguishable from inside the process. A cliloc file is one file with one hash, and a partial copy makes the parser fail on a truncated record — the ambiguity the atlas must escalate is one this parser simply detects, so it refuses the import and leaves the previous table serving.

Resolution is server-side and there is no public route. The table is never served as a table: 67k rows would dwarf any page using them, and the Android client consumes the same already-resolved JSON. resolveMany() returns only ids that resolved to something displayable — placeholders like ~1_val~ are stripped, since the bridge sends the id and never the property packet that carries the arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.


4. API contract

Base path /api/v1. JSON in/out. Auth via httpOnly cookie (isLoggedIn reads it; also accepts Authorization: Bearer for API testing).

4.0 The authoritative route list

The prose tables below are orientation for a human reader and can drift. Two generated artifacts are authoritative, and they answer different questions:

Artifact Source of truth for Generated by
server/routes.manifest.json — mirrored as api-route-inventory.json What URLs exist. 215 public routes + 2 on the internal listener, sorted, method + path only. npm run routes:manifest, by walking the live Express stack
server/swagger/swagger-output.json — served at /api/docs What each route means. Parameters, bodies, response codes, security. npm run swagger, from #swagger.* annotations

The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and it churns whenever a description is reworded — it documents intent. The manifest is introspection- derived and records reality, which is why it, not Swagger, is the thing PR checks freeze (npm run routes:manifest -- --check).

Both artifacts are emitted with sorted keys, so a diff in either is proportional to the change rather than to how the routers happen to be traversed. swagger.js additionally strips trailing slashes from generated path keys — see Regenerating the spec in the website README for why the domain split makes that necessary.

Scope: the manifest keeps /api/** and /.well-known/** from the public app plus everything on the internal listener. The SPA catch-all, /uploads and /brand are filesystem-conditional static mounts — not API contract, and including them would make the output depend on whether CI had built the client.

A third generated file, server/routes.guards.json, is a review aid and not a contract: per route, the middleware handler count plus the named middleware on its mount chain. It exists because a router-level router.use(noindex, isLoggedIn, staffOnly) gate never appears in an individual route's own stack, so a capability router extracted without re-applying the gate would otherwise publish authenticated endpoints silently. Names are a hint only — requireRole(...) returns an anonymous arrow and cannot be observed — but a missing requireAuth is unambiguous, and the server test suite asserts every /admin/** and /player/** route still carries it.

/auth (auth/index.js → the capability routers in §2)

No group gate — /auth is where an anonymous caller becomes authenticated. The authenticated parts gate themselves: me.routes.js and notifications.routes.js each apply noindex, requireAuth at their own router level, and /sso/:provider/link carries requireAuth per route.

Method Path Auth Body Purpose
POST /login — (rate-limited) {username,password} verify, set cookie, log auth.login, update last_login_at. If the account has TOTP and this browser is a trusted device (a valid rg_trust cookie bound to the user), the TOTP step is skipped and a session is issued directly (logs auth.login.trusted_device). Otherwise a 2FA account returns {totpRequired, challenge}.
POST /login/totp — (rate-limited) {challenge, code? | recoveryCode?, trustDevice?, deviceName?} complete 2FA with a TOTP or single-use recovery code. trustDevice sets the rg_trust cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries {trustLimitReached, devices}.
POST /logout cookie clear cookie (the rg_trust trust cookie deliberately survives logout)
GET /me cookie / bearer current user (no hash) or 401 — client bootstraps auth state
POST /password/forgot — (rate-limited) {email} email a single-use, ~1h reset link to every active account on the address; always returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs account.password.reset.request.
GET /password/reset/:token validate a link → {username} for the form, else 404 (never distinguishes expired/used/never-existed)
POST /password/reset/:token — (rate-limited) {password} consume the single-use link, rotate the hash, and revoke all sessions (web cutoff + mobile refresh tokens). Does not sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs account.password.reset.complete.
GET /me/account cookie / bearer full self account (id, username, role, email, status, totp_enabled, has_password)
PATCH /me/account/username cookie / bearer (rate-limited) {username} change own username; re-issues the caller's session
PATCH /me/account/password cookie / bearer (rate-limited) {newPassword, currentPassword?} change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's
POST /me/account/totp/setup · …/enable · …/disable cookie / bearer {code} on enable/disable self 2FA enrollment (disable needs a valid current code, not a password). enable returns the one-time recoveryCodes; disable clears the user's trusted devices + recovery codes
GET /me/account/identities · DELETE …/:provider cookie / bearer list / unlink own SSO identities
GET /me/trusted-devices cookie / bearer list own active trusted devices (never tokens)
POST /me/trusted-devices cookie / bearer (rate-limited) {deviceName?} trust the current device; web gets an httpOnly rg_trust cookie, native gets {trustToken}. 409 {error:'trusted_device_limit', devices} at the cap
DELETE /me/trusted-devices · …/:id cookie / bearer untrust all / one (ownership-scoped)
GET /me/account/recovery-codes/status cookie / bearer remaining unused code count (never the codes)
POST /me/account/recovery-codes/generate cookie / bearer (rate-limited, password step-up) {currentPassword?} regenerate the one-time recovery codes (returned once); refused when 2FA is off
POST /me/devices cookie / bearer {endpoint, transport?, platform?} register a push endpoint; rejects a disallowed endpoint 400 (SSRF guard). Idempotent per (user, endpoint)
GET /me/devices · DELETE …/:id cookie / bearer list / unregister own push devices
GET /me/notifications/streams cookie / bearer the subscribable catalog (personal/requiresLinkedAccount flags)
GET · PUT /me/notifications/subscriptions cookie / bearer {streams:[id]} on PUT get / replace own opted-in streams (unknown ids dropped)

Role-agnostic self-service (/auth/me/*). The canonical "me" surface for every authenticated role. It reuses the exact account.controller handlers as /player/account/* and /admin/account/* (no logic duplication) behind requireAuth only — any active account, never a specific role. This lets a client (the Android app) manage its own account through one surface without ever touching /admin (docs/android/PLAN.md §6.4). The older /player/account/* + /admin/account/* routes stay for web back-compat.

The /player/* group is self-service, not player-only. Staff are a superset of players — every player ability plus their staff tools on top — so the whole group (account.router.js, shard.router.js, appeals.router.js, mounted by player/index.js) sits behind the shared noindex, requireAuth gate only, never requireRole('player'). Every handler is self-scoped to the caller by req.user.id, so an admin/editor/ moderator using it sees only their own linked accounts and characters (with the pre-existing isAdmin bypass still letting a genuine admin read any character). Staff also reach the identical self-scoped handlers under /admin/shard/* (same controller) for the web admin surface; the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and personal notification streams on the mobile client — the group no longer 403s a non-player role.

Password reset. Uses the same audited pattern as user_invites: an opaque 32-byte token whose sha256 hash only is stored in password_resets, single-use and short-lived (~1h). It also serves SSO-only accounts (null password_hash) as their "set an initial password" path. The reset link points at the web front end (/account/reset/:token); the Android app hands off here rather than shipping its own reset screen (docs/android/PLAN.md §4.2). First admin is bootstrapped by seed.js from env (see §6); further staff are created under /admin/users or via email invites.

Push notifications (M7, opt-in). The app subscribes per stream (/auth/me/notifications/*) and registers device endpoints (/auth/me/devices); nothing is pushed unless subscribed. Delivery is a content-free tickle{ stream, ref }, no sensitive data — POSTed to each subscribed device's self-hosted ntfy endpoint (utils/pushDispatch); the app wakes and pulls the real, ownership- checked content over the authenticated API. Two producers fan out through the one publisher: the shard ingest dispatcher (utils/shardIngest, beside the SSE broadcast) for shard-derived streams, and the create/publish-post path for news.post. The stream catalog + event→stream mapping is config/notificationStreams.js. Security invariants:

  • Same public/admin split as the SSE feed. Public streams are drawn only from the SSE PUBLIC_KINDS allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public push.
  • Personal streams are owner-keyed. vendor.sale / house.idoc / account.login are delivered only to the owning user's devices, resolved via shardLinks (the same ownership check as /player/shard/*).
  • SSRF guard. A device endpoint is a client-supplied URL the server POSTs to, so registration and every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy allow-set (NTFY_BASE_URL / NTFY_ALLOWED_ORIGINS).
  • ntfy is treated as an untrusted relay — no per-user accounts, unguessable topics; an optional NTFY_PUBLISH_TOKEN hardens backend→ntfy publishes but is not required. See docs/android/PLAN.md §11.

Mobile SSO Authorization Bridge (/auth/mobile/sso/*, M9)

Native "Sign in with Google/Discord" for the Android app without shipping any OAuth secret in the app. The website stays the identity authority: each shard owner's provider credentials live in auth_providers (encrypted at rest) and are only ever used server-side. The bridge is a new consumer of the existing SSO + mobile-bearer machinery, not a parallel auth path — it reuses the /auth/sso/:provider/* redirect flow, the link-only + opt-in-provisioning policy, the TOTP gate, and issues the same token pair as /auth/mobile/login.

The TOTP gate it reuses includes the trusted-device skip (see TRUSTED_DEVICES_MFA.md §6). Because the app opens this flow in a Custom Tab, which shares the system browser's cookie jar, the rg_trust cookie set on the TOTP form is presented back on the next app sign-in — so "don't ask me again" works for native SSO without the app injecting a header into a tab it does not control, and without a trust token ever appearing in a start URL.

Method Path Auth Body / Query Purpose
GET /auth/providers reused discovery; the app renders provider buttons from this (never exposes secrets)
GET /auth/mobile/sso/start — (rate-limited per-IP + per-provider) ?provider&code_challenge&state&redirect_uri validate provider enabled + redirect_uri exact-match allowlist; insert a mobile_auth_sessions row; create the existing sso_tx tagged mode:'mobile' carrying session_id; 302 to the IdP (existing authorize URL)
GET /auth/sso/:provider/callback — (signed sso_tx) ?code&state existing endpoint; a new branch when tx.mode==='mobile': resolve the account (same policy as web login incl. TOTP), mint a single-use hashed authorization code into mobile_auth_codes, mark the session completed, and 302 to redirect_uri?code=…&state=… (the app's original state) — no cookie is set
POST /auth/mobile/sso/exchange — (rate-limited per-IP) {code, code_verifier} validate the code exists / unexpired / unused (mark used) and sha256(code_verifier) matches the stored challenge → issue the existing mobile access + refresh pair (createMobileSession) → {accessToken, refreshToken, expiresIn, user}. When the session carries trust_device, also mint a platform:'mobile' trusted device and add trustToken — minted here, on an authenticated app→server call, so it never travels in the deep link. Best-effort: at the trusted-device cap the response simply omits it rather than failing the sign-in
POST /auth/mobile/refresh {refreshToken} reused unchanged — rotate the pair
POST /auth/mobile/logout bearer {refreshToken?, all?} reused unchanged — revoke this (or all) refresh token(s)
GET /auth/me/sessions · DELETE …/:id cookie / bearer list / revoke own mobile sessions (device_name, last_used_at, created_at) — the "Active Devices" surface (distinct from /auth/me/devices, which is push endpoints)

Two PKCE layers (do not conflate).

  • Layer A (existing): website ↔ IdP. The code_verifier is generated at /start, kept only in the httpOnly sso_tx cookie, sent to the IdP token endpoint at the callback. Unchanged.
  • Layer B (new): app ↔ website. The app generates code_verifier/code_challenge; the challenge is stored in mobile_auth_sessions at /start; the verifier is presented at /exchange. This is what stops an intercepted callback code from being redeemed by anyone but the real app.

State / CSRF. The app-generated state is stored at /start, echoed on the callback redirect, and verified by the app before it calls /exchange — a CSRF guard independent of both PKCE layers (a different app instance triggering /start cannot complete someone else's flow).

Redirect-URI allowlist. /start and the callback validate redirect_uri by exact match against a configured allowlist (MOBILE_AUTH_REDIRECT_URIS, default the one fixed application-owned callback runicgateway://auth/callback) — never prefix match (prefix matching on custom schemes is a known open-redirect vector). Tokens are never placed in the callback URL — only the short-lived authorization code.

App Links (implemented). When the admin toggle mobile_app_links_enabled is on, /start also accepts the self-origin HTTPS callback https://<request-host>/mobile/callback — one additive exact-match entry, derived from the request/APP_BASE_URL and never from client input; the custom-scheme allowlist is never narrowed. The shard then auto-serves GET /.well-known/assetlinks.json (fixed package com.runicgateway.app + MOBILE_APP_CERT_SHA256 fingerprints; 404 when the toggle is off or no fingerprint is configured), and settings.getPublic() advertises mobileAppLinks: <bool>. These two things — one static file route and one more allowlist entry — are the entire server surface App Links require. See docs/android/APP_LINKS.md.

TOTP through the bridge. A 2FA account keeps full parity: the callback stages the existing pending-TOTP cookie (now also carrying the bridge session_id) and bounces the Custom Tab through the web TOTP form; on a correct code the completion mints the authorization code and deep-links back to the app — it never mints a session cookie for a mobile flow.

Revocation latency (documented tradeoff). Revoking a refresh token (device revoke / logout) stops future renewals but does not invalidate an already-issued access token until it expires — up to the access-token lifetime (MOBILE_ACCESS_TTL, default 15 min) of continued access. This is an accepted tradeoff given the short lifetime. If instant revocation is ever required, add an access-token (jti) blocklist check on the requireAuth path — the same revoked_sessions mechanism web sessions already use.

Authorization code. Cryptographically random, ≥128 bits, stored hash-only, single-use, short expiry (~5 min); /exchange is rate-limited per-IP. The bridge tables self-prune (§3).

/public (public/index.js → the capability routers in §2) — all GET except /contact, no auth

No group gate, deliberately. This surface is anonymous by design: the SPA renders it logged-out, the Discord bot reads it with no credentials, and the Android ShardStreamClient consumes /public/shard/stream without an Authorization header. Content visibility during maintenance comes from the per-route siteMode middleware (§5), never from an auth gate.

Method Path Notes
GET /settings whitelisted public keys, derived registration/gameAccountSignup flags, the per-shard brand block (name, accent color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a push block { ntfyUrl } (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from NTFY_PUBLIC_URL / first NTFY_ALLOWED_ORIGINS (never the internal NTFY_BASE_URL); null when push isn't configured for the shard.
GET /status status message + current mode, plus a version block ({ service:'runic-gateway', api, server }) so a client first-run probe recognizes the backend and can run a version-mismatch guard
GET /version lightweight, DB-free backend identity/version ({ service, api, server }) — the canonical target for the version guard and a cheap liveness check
GET /posts/:category published only; category ∈ news|five-on-friday|newsletter|screenshots
GET /posts/:category/:idOrSlug single published post
GET /wiki list of pages (slug + title)
GET /wiki/:slug single page
POST /contact (rate-limited) send mail via SMTP; if unconfigured, respond {fallback:"mailto", email}
GET /shard/ruleset the shard's own published ruleset (Protocol 3.0 world.ruleset): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from shard_ruleset, so it renders while the shard is down; live via world.ruleset on /shard/stream. Behind requireFeature('ruleset'). null means the shard has never published one — a real answer, distinct from a published ruleset. caps.skill / caps.totalSkill are in tenths (1000 = 100.0).
GET /shard/points every points/loyalty leaderboard the shard publishes (Protocol 3.0 points.board) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from shard_points_boards, so it renders while the shard is down; live via points.board on /shard/stream. Behind requireFeature('leaderboards'), ordered by display name. maxPoints: 0 means uncapped (the common case), and nameString is usually null with nameNumber holding a cliloc — resolve client-side or humanise the system key.
GET /shard/points/:system one board by the shard's PointsType name (e.g. QueensLoyalty); :system must match /^[A-Za-z][A-Za-z0-9_]{0,47}$/ or 400 before any query runs. 404 = the shard has never published that system, which is distinct from a published board nobody has scored in yet (200 with an empty top).
GET /shard/features the shard features this caller may reach plus the audience rung they resolved to (§6.5), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav.
GET /atlas/creatures?q=&facet=&limit=&offset= the bestiary, most numerous first, with an unpaginated total. Static content parsed from the shard's ServUO tree — not sidecar-backed, which is why the atlas sits outside /shard, and unlike /shard/* it is site-mode gated. Behind requireFeature('atlas'). ?facet= is matched exactly and never validated against a list (no facet name exists in the code); the filter is an EXISTS over the points rather than a JSON path or JSON_SEARCH built from caller input, whose %/_ wildcards would make ?facet=% match everything.
GET /atlas/creatures/:slug one creature: places (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), spawners (the bounded raw list, with spawnersTruncated), alsoHere. points is a COUNT and spawners is the LIST — named apart so one key never means a number on one route and an array on another. minDelay/maxDelay are in seconds, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas.
GET /atlas/regions?facet=&q= named regions and the rectangles that placed each spawner
GET /atlas/landmarks?facet=&q= points of interest, labelled by group ("Covetous", not "Level 1")
GET /atlas/champions?facet= the configured altar roster. Not /shard/champs, which is the live board.
GET /atlas/meta facets, counts and when the atlas was parsed. Game-world facts only — the ServUO path, source hashes and any pending refresh are operator detail and live on the admin route.

Public content GETs pass through the siteMode gate (§5).

/admin (admin/index.js → the capability routers in §2) — all behind isLoggedIn + noindex + staffOnly

admin/index.js applies the shared gate and mounts each capability router at the prefix it owns; users, invites, auth/providers and bot-activity add adminOnly on top, and moderation adds modAccess (admin + moderator, so editors are excluded). The content capabilities — posts, uploads, wiki, pages — add nothing: managing content is the editor tier's job, so staffOnly is the whole gate. The ops/config capabilities — uo-link, email, discord-bot, settings, and PUT /site-mode — are adminOnly; shard is the one mixed prefix, where self-service account linking carries no extra gate and the in-game staff operations carry modAccess. There is no residual file: every admin route is declared in a capability router.

GET/PUT /admin/shard/visibility are the third tier on that mixed prefix: adminOnly, because they decide what anonymous visitors can see (§6.5). They sit above modAccess deliberately — a moderator can ban a player but cannot decide what the public internet reads.

GET /dashboard and PUT /site-mode are the one place where a single screen spans two tiers: the dashboard is staff-wide, but the site-mode toggle on it is adminOnly. The client must therefore gate that control on its own (Dashboard.jsx renders it only for role === 'admin') rather than relying on the route gate that admitted them to the page — the same rule the sidebar follows, so a non-admin is never shown a control that would 403. The URLs below are unaffected by which file a route sits in — that is the property the route manifest freezes.

Method Path Purpose
GET /dashboard current mode, last change time + who, content counts, recent activity
PUT /site-mode {mode} → update settings, stamp who/when, log site_mode.change
GET /posts?category= all posts incl. unpublished
POST /posts create
GET /posts/:id one
PUT /posts/:id edit
DELETE /posts/:id delete
PATCH /posts/:id/publish {published} toggle (sets published_at)
POST /posts/upload multipart image upload (multer) → {image_url} for screenshots
GET /wiki · GET /wiki/:slug read incl. unpublished
POST /wiki · PUT /wiki/:slug · DELETE /wiki/:slug manage pages
GET /settings · PUT /settings read all / update {key:value,...}
GET /activity?limit=&offset= paginated activity log
GET /users · POST /users · PUT /users/:id · DELETE /users/:id user mgmt (can't delete self / last admin; password hashed on write)
GET /users/:id/trusted-devices list a user's active trusted devices (never tokens)
DELETE /users/:id/trusted-devices · …/:deviceId revoke all / one of a user's trusted devices (logs admin.trusted_device.revoke[_all])
POST /users/:id/mfa/reset recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs admin.user.totp.reset)
GET /shard/atlas spawn-atlas status (adminOnly): the ServUO path, whether the tree is readable, whether it has drifted from what is loaded, counts, facets, and any refresh staged for review. The public /atlas/meta reports the game world only; the filesystem detail is here.
POST /shard/atlas/import re-import without restarting; {force} ignores the hash gate. An unreadable tree answers 200 with status:"unavailable", not 500refresh() reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API.
POST /shard/atlas/approve · /shard/atlas/reject answer a refresh staged because it would REMOVE a facet. Approving re-parses the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged.
PUT /shard/atlas/path point the atlas at a different tree (persisted as spawn_atlas_servuo_path, which wins over SERVUO_PATH). Blank clears it. Deliberately does not import — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next.
GET /shard/clilocs cliloc-table status (adminOnly): the configured path, the file actually resolved (the path may be a directory), readability, drift against what is loaded, and the entry count. configured:false is a supported state — item names then render as ids. No public counterpart: the table is never served as a table.
POST /shard/clilocs/import reload after a client patch; {force} ignores the hash gate. A missing file — or the likely mistake of pointing at the client's own COMPRESSED Cliloc.enu — answers 200 with status:"unavailable" and a code, not 500. COMPRESSED is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert.
PUT /shard/clilocs/path point the site at a different cliloc file or directory (persisted as cliloc_client_path, which wins over UO_CLIENT_PATH). Blank clears it. Deliberately does not import, same reasoning as the atlas path.

Every admin write logs to activity_log.


5. Site mode (LIVE / MAINTENANCE)

State in settings.site_mode (live|maintenance), default maintenance.

middleware/siteMode.js, applied only to public content routes:

  • live → pass through.
  • maintenance → respond 503 with {mode:"maintenance", message} unless the request carries a valid admin cookie (admin preview). This hides content server-side, not just in the UI.

Always reachable regardless of mode: static assets / SPA shell, /api/v1/auth/*, all /api/v1/admin/*. So admin login + panel + the maintenance "coming soon" page always load.

Client behavior (Phase 3): reads GET /public/settings; if maintenance and not an admin previewing, render the polished dark coming-soon page (message + contact email). Admin "preview live" simply hits the content APIs with the admin cookie, which bypass the gate.

Dashboard reads site_mode + site_mode_changed_at/_by for "current mode + last change + who"; activity_log provides the history feed.


6. Auth & security

  • JWT signed with JWT_SECRET, expiresIn=JWT_EXPIRES_IN (default 1d); payload {id,username,role}.
  • Cookie: httpOnly, sameSite=Lax, path=/, and secure decided per-request (COOKIE_SECURE=autosecure: req.secure).
  • Trusted-device MFA. A second, separate httpOnly cookie (rg_trust, default 30d) — opaque, sha256-hashed server-side in trusted_devices — lets a browser/app skip the TOTP step (never the password) on future logins. It is a server-side, per-row-revocable record (never a JWT claim), so the stateless session JWT is unchanged and trust stays revocable. It only ever gates the second factor; it deliberately outlives logout, and is cleared on untrust / password change / password reset / TOTP disable. Recovery codes (bcrypt, single-use) are the 2FA-lockout fallback. All admin trusted-device/MFA actions and the self actions (auth.login.trusted_device, account.trusted_device.*, account.recovery_code*, admin.trusted_device.*, admin.user.totp.reset) are audit-logged. See docs/website/TRUSTED_DEVICES_MFA.md. This is the key to dual access: the cookie is Secure when reached through Pangolin (HTTPS, X-Forwarded-Proto: https) but not Secure when reached directly over the LAN IP on plain HTTP — so login works in both. COOKIE_SECURE=true|false can force it. Requires trust proxy (below). localhost:5173 (Vite) and localhost:3000 are same-site, so the cookie flows in dev too.
  • bcrypt hashing (cost 10+); plaintext passwords never stored, logged, or returned.
  • Rate limiting (express-rate-limit) on /auth/login and /public/contact.
  • Validation (express-validator) on all writes; centralized error handler.
  • helmet with a Content-Security-Policy tuned for the built React SPA. The policies now live in server/src/config/csp.js (app.js only wires them up): default-src 'self'; script-src 'self' (the Vite build emits only external module chunks — the inline module-preload polyfill is disabled in client/vite.config.js to keep this valid); style-src 'self' 'unsafe-inline' https://fonts.googleapis.com (React's pervasive inline style={{…}} attributes can't be nonce'd, plus the Google Fonts stylesheet); font-src 'self' https://fonts.gstatic.com (Cinzel); img-src 'self' data: https: (same-origin uploads, plus external https images embedded in wiki/news bodies or BRAND_* logo/hero/favicon); connect-src 'self' (REST + SSE are same-origin); frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self' (blocks an injected <form action="https://evil"> from POSTing credentials off-origin — an exfil path connect-src does not cover; it was always emitted via helmet's useDefaults and is now pinned explicitly so it cannot vanish under a helmet upgrade). upgrade-insecure-requests is intentionally not set (TLS terminates at the proxy, there are no mixed-content subresources, and it would break a local npm start over plain http). The /api/docs Swagger UI route gets a looser policy that additionally allows inline script/style, since swagger-ui-express injects an inline bootstrap. helmet also strips X-Powered-By; the two internal-only listeners (internalApp.js, bot/src/app.js) disable it explicitly too.
  • A second, tightened policy ships alongside on Content-Security-Policy-Report-Only for one release before it replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1). It is derived from the enforced policy so the two cannot drift, and differs by exactly one directive: frame-ancestors 'self''none'. Serving both headers at once means the live policy keeps protecting users while anything the tightened version would break arrives as a report rather than as a broken page — and for frame-ancestors specifically, a report from the browser of whoever framed the site is the only way to learn that something does.
  • POST /api/csp-report is the same-origin violation sink that report-to / report-uri point at (report-to additionally requires the Reporting-Endpoints response header, which is set alongside). Same-origin on purpose: reports describe attacks against this site and are not handed to a third-party collector. It parses both wire formats (application/csp-report from Firefox/Safari, application/reports+json from Chrome's Reporting API — handling one drops half the browsers), writes to the csp log tag and stores nothing. Necessarily unauthenticated (browsers send reports with no session), so it is bounded on every axis: 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field truncated, and always 204 — even for malformed input, since a 4xx would make the global error handler log the attacker-supplied body and turn an open endpoint into a log-flood primitive. 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 part of the versioned client contract.
  • Admin not indexed: X-Robots-Tag: noindex, nofollow on /api/v1/admin and the admin SPA routes; robots.txt disallows /admin.
  • No directory browsing (express.static doesn't list; no serve-index).
  • No hardcoded credentials: first admin via seed.js reading ADMIN_USERNAME/ADMIN_PASSWORD from env (created only if no users exist); .env git-ignored, .env.example committed.
  • app.set('trust proxy', 1) so secure cookies, req.ip, and rate-limiting work behind Pangolin.
  • CORS: same-origin in prod (SPA served by Express). Dev only: allow CLIENT_ORIGIN (Vite, http://localhost:5173) with credentials:true.

6.5 Shard visibility — the audience boundary (Protocol 3.0)

Every shard-derived surface is gated by an admin-configurable, per-feature and per-field audience setting. This replaces the static PUBLIC_KINDS allowlist that used to be the whole boundary. Policy lives in utils/shardVisibility.js; rows live in shard_feature_visibility; the admin surface is GET/PUT /admin/shard/visibility (adminOnly). Admin-facing guide: SHARD_VISIBILITY.md. Design: ../link/v3.md §3.

The ladder. anonymous < logged_in < player < staff < admin, each rung implying the ones below. viewerLevel(req) resolves it: no session ⇒ anonymous; authenticated ⇒ logged_in; authenticated with a linked game account ⇒ player; moderator ⇒ staff; admin ⇒ admin. Staff satisfy the player rung without a linked account (consistent with /player/* being role-agnostic). editor gets no shard privilege — it is a content role, and mapping it to staff would silently widen what editors see.

Two invariants that are code, not configuration. Both are enforced server-side and both reject rather than silently ignore:

  1. acct and webId are admin-only, always. They are not exposed as configurable fields, and a stored row attempting to loosen them is discarded on read as well as rejected on write. A character name is visible in game; the account behind it and the website user it links to are not. The lock is on the field's meaning, not one spelling: isLockedField(key) matches a key that is or ends in acct/webId, case-insensitively, so the flattened forms the read models emit (shapeHouseownerAcct, shapeGuildleaderWebId) are covered too. An exact-key check was the original implementation and it let GET /public/shard/idoc serve ownerAcct anonymously.
  2. A kind absent from KIND_FEATURE is never broadcast below admin. Fail closed. This is what keeps the kind map a security boundary rather than a convenience filter, and it means a shard that starts emitting an unknown event degrades to staff-only, never to public.

Fail-closed everywhere else too. An unreadable visibility config withholds every public frame; a DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer subscribes as anonymous. The ladder comparison uses asymmetric fallbacks by design — an unknown viewer level floors to the bottom rung and an unknown requirement ceils to admin, so an unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction it picks, it fails open on one side.)

Three enforcement points, one config:

Where Mechanism
Routes requireFeature(name)404 when the feature is disabled (don't leak that it exists), 403 when the caller is below its audience. projectFeature then strips out-of-rung fields from the body.
SSE (utils/shardBroadcast.js) Per-connection filtering. A subscriber's rung is resolved once at subscribe time and frozen for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing.
Nav GET /public/shard/features returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only.

Config reads are cached ~5s, so admin changes take effect within seconds including on already-open streams. PUBLIC_KINDS still exists and is still exported (notificationStreams.js) but is now derived from the kind map rather than hand-maintained, so the two cannot drift.

PUBLIC_KINDS is a module-load constant and must not be used to answer "may this caller read this kind?" — it is computed from the compiled defaults, so it cannot see an admin's changes. Use visibleKinds(level, config), which resolves against the live config. /feed uses it; it originally used PUBLIC_KINDS and consequently kept serving guild.join to anonymous callers after an admin had moved guilds to staff. visibleKinds deliberately ignores the stream flag: that governs SSE fan-out only, so a feature whose live firehose ships off (market) stays readable from stored history.

Every read path that returns shard data must call projectFeature. The stored-history endpoints are not exempt — /feed returns the same events the stream does, and returning them unprojected reopens on the REST side exactly what the stream closes. Relatedly, shardEvents.db.list treats an empty kinds array as "serve nothing", never "no filter"; the fall-through it used to take would have turned a fully-gated config into a dump of the entire event log.

projectFeature walks arrays and plain objects only. A Date, Buffer or other class instance is passed through as a value — rebuilding one key-by-key yields {}, which is the difference between the pure-JSON wire frames and the DB-backed read models whose rows carry real Date columns.

Defaults reproduce pre-3.0 behavior exactly, so installing the framework is a no-op until an admin changes something — with deliberate exceptions, which are the leaks it was written to close. /public/shard/guilds, /public/shard/governors and /public/shard/feed previously returned the raw stored payload, whose actors carry acct and webId; /public/shard/idoc returned the flattened ownerAcct. All are now stripped for every caller below admin.


7. Email

utils/mailer.js (nodemailer) sends through Gmail over OAuth2 (SMTP XOAUTH2), configured in Admin → Settings → Email — not env. The mailbox is authorized by an in-app "Connect Gmail" consent flow (/admin/email/*) that captures a refresh token, stored AES-GCM-encrypted in the email_config singleton (never returned over the API). The OAuth client id/secret are reused from the google auth-providers row. Recipient is the contact_email site setting. If email is unconfigured/disabled, POST /public/contact returns {fallback:"mailto", email} so the client renders a mailto: link instead. Errors never leak credentials.


7.5 Logging & observability

utils/logger.js — a small dependency-free logger with two transports, console + file, and four levels (error/warn/info/debug). Each line is timestamped and tagged by subsystem ([server], [http], [db], [auth], [admin], [ratelimit], [csp], …).

During the CSP report-only soak, [csp] is the tag to watch: a csp violation warn line with directive: frame-ancestors means something really does frame the site and the enforce PR would break it. Silence across one release is the green light to flip.

  • Console: color on a TTY, plain in Docker; verbosity = LOG_LEVEL (default info).
  • File: plain text appended to LOG_DIR/LOG_FILE (default <server>/logs/app.log, /app/logs/app.log in Docker, bind-mounted to ./logs); verbosity = FILE_LOG_LEVEL (default debug, so the file keeps a complete record while the console stays readable). Toggle with LOG_TO_FILE. The stream is flushed on graceful shutdown.
  • HTTP access logs via morgan piped into the logger: real client IP (trust proxy), authenticated admin username, method, URL, status, response time, size.
  • Captured events: startup config banner, schema/seed steps, login success/failure, rate-limit hits, site-mode changes, maintenance-gate blocks (debug), all errors with stack traces (5xx), and SIGINT/SIGTERM shutdown. Passwords and request bodies are never logged. unhandledRejection/uncaughtException are caught and logged.

8. Deployment

docker-compose.yml — two services on a private network:

  • db: mariadb:11, env MARIADB_DATABASE/USER/PASSWORD/ROOT_PASSWORD, volume dbdata:/var/lib/mysql, mounts schema.sql into /docker-entrypoint-initdb.d, healthcheck.
  • app: builds the Dockerfile (installs client+server, builds Vite, serves via Express), env_file: .env, DB_HOST=db, depends_on: db (healthy), volume uploads:/app/uploads, ports: "3000:3000"binds 0.0.0.0 (no 127.0.0.1: prefix) so Pangolin reaches it.
  • ntfy (M7): pinned upstream binwiederhier/ntfy image, declarative config only (./ntfy/server.yml mounted :ro + NTFY_BASE_URL), volume ntfydata:/var/lib/ntfy, publishes :80 on a host port (${NTFY_HOST_PORT:-2586}:80, binds 0.0.0.0) so Pangolin — which runs outside the compose network — can forward the notification subdomain to it, the same reason app publishes 3000. Both devices (SSE subscribe) and the backend publisher (POSTing tickles to registered device endpoints) reach ntfy on that public origin. Anonymous read-write to unguessable topics (no accounts to provision) — safe because pushes are content-free tickles. Bringing the stack up provisions a working push relay with zero interactive setup.
  • Volumes: dbdata, uploads, ntfydata.

Express listens on 0.0.0.0:${PORT||3000}. Pangolin terminates TLS and proxies to app.

.env.example (committed; real .env ignored):

NODE_ENV=production
PORT=3000
DB_HOST=db
DB_PORT=3306
DB_NAME=uomysticmoon
DB_USER=uomm
DB_PASSWORD=
DB_ROOT_PASSWORD=
JWT_SECRET=
JWT_EXPIRES_IN=1d
COOKIE_SECURE=true
COOKIE_NAME=uomm_token
ADMIN_USERNAME=
ADMIN_PASSWORD=
# Email: configured in Admin → Settings → Email (Gmail OAuth2), not via env
CLIENT_ORIGIN=http://localhost:5173
# Push (M7): the ntfy relay URL — also the backend's SSRF allow-set for device
# endpoints. NTFY_ALLOWED_ORIGINS / NTFY_PUBLISH_TOKEN are optional.
NTFY_BASE_URL=https://ntfy.example.com
# The client-facing ntfy URL surfaced to the app via /public/settings.push.ntfyUrl
# (the app registers its topic endpoint here). Defaults to the first
# NTFY_ALLOWED_ORIGINS entry; set explicitly when the public URL differs from the
# internal NTFY_BASE_URL. Without it (and without NTFY_ALLOWED_ORIGINS) the app
# shows push as unavailable for the shard.
NTFY_PUBLIC_URL=https://ntfy.example.com
NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# Host port the ntfy container publishes :80 on (default 2586); the reverse proxy
# forwards the notification subdomain to host:NTFY_HOST_PORT. Change on a conflict.
NTFY_HOST_PORT=2586

.gitignore: node_modules/, .env, _reference/, client/dist/, uploads/.


9. Dependencies (server)

express, cors, helmet, morgan, dotenv, mariadb, jsonwebtoken, bcryptjs, cookie-parser, express-rate-limit, express-validator, multer, nodemailer · dev: nodemon. Removed vs serverlinkr: mongoose, mongodb, connect-mongo, express-session, passport, passport-local.


10. Spec coverage

Spec requirement Covered by
Public pages (/, /site/*, /wiki/*) /public/* API + Phase-3 SPA routes; content from posts/wiki/settings
News / 5-on-Friday / Newsletter / Screenshots posts table, category column; admin CRUD + publish
Wiki 8 categories, editable later wiki_pages seeded with 8 slugs; admin CRUD
Status page settings.status_message + mode via /public/status
Admin dashboard (mode, last change, who) /admin/dashboard + settings stamps + activity log
Site mode toggle PUT /admin/site-mode + siteMode middleware
Admin activity log activity_log + /admin/activity
Admin user management /admin/users CRUD
Site settings editing /admin/settings
JWT, bcrypt, rate limit, secure cookies, noindex, no dir browsing, no hardcoded creds, .env §6
Maintenance page, admin always in, static always loads, admin preview §5
SMTP via env, mailto fallback §7
Docker Compose + MariaDB + Pangolin, 0.0.0.0 bind §8
Design tokens / hero reused from existing assets/css/mysticmoon.css + hero PNG in Phase 2/3
Expandable key/value settings, role enum, modular routers/models