Phase 4 slice 4. BACKEND_DESIGN.md had never been de-UO'd: Phase 3 rewrote core's code and README, but §5.2's identifier check reads code, not prose, so nothing ever looked at the design document. It still described 27 shard_* tables, 20 UO route rows and the shard visibility ladder as core's, a phase after core stopped being able to serve any of them. Moved, text unchanged: BACKEND_DESIGN §3 six shard_* schema sections (226 lines) → modules/uo/SCHEMA.md BACKEND_DESIGN §4 13 public + 7 admin UO route rows → modules/uo/API.md BACKEND_DESIGN §6.5 the audience ladder (70 lines) → modules/uo/API.md §4 Core keeps the seam and gains the eight /admin/modules routes it had never documented. §6.5 becomes "Module-owned audience boundaries": core's security boundary ends at authentication, roles and the session, and a module that serves game data brings its own. Also fixed on the way: users.router.js was still listed as 15 routes (it is 9 — six went to the extension slot), and the push section still promised config/shardStreams.js "moves out with it" four slices after it left. The acceptance table now carries results. Criterion 2 was proved for real against module-uo v0.3.0 on an empty database, which turned up the uninstall ordering defect fixed in website#146. AI disclosure: this contribution was AI-assisted (Claude Code). Co-Authored-By: Claude <noreply@anthropic.com>
17 KiB
module-uo — its HTTP surface
The 72 URLs module-uo serves, and the audience boundary that gates them. Frozen in the module's
own routes.manifest.json
and documented operation-by-operation in its
swagger-fragment.json,
which core merges into /api/docs.json while the module is running — so the live Swagger UI is
always the most complete answer.
This page moved out of BACKEND_DESIGN.md §4 and §6.5 when
Phase 4 closed (MODULE_SYSTEM.md §2.7.2). Core's API contract
describes core's routes; these are the module's, and core cannot answer them with the module absent.
The tables and the reasoning are unchanged.
Every URL is byte-identical to the one core served before the extraction — that is §1.2 of the
module plan, and it is what lets the shipped Android app keep calling
POST /api/v1/admin/shard/kick and the Discord bot keep reading /api/v1/public/shard/* without
knowing a module answers now.
1. The mounts
| Mount | Routes | Tier and gate |
|---|---|---|
/api/v1/public/shard |
19 | Anonymous. Never site-mode gated — the shard surface stays readable during maintenance, per feature audience. |
/api/v1/public/atlas |
6 | Anonymous, and unlike /shard it is site-mode gated: nothing here touches the sidecar, it is parsed shard content. |
/api/v1/admin/shard |
26 | Behind core's isLoggedIn + noindex + staffOnly group gate, then mixed per route — see below. |
/api/v1/admin/uo-link |
7 | adminOnly. The sidecar connection config, its live status, the admin SSE stream and the town crier. |
/api/v1/player/shard |
8 | requireAuth, any role — staff are a superset of players — and every handler is self-scoped to req.user.id. |
/api/v1/admin/users/:id/shard/* |
6 | adminOnly. The module's routes hanging off a core resource, through core's admin.users.detail extension slot: core owns the user, the module owns what it knows about their game accounts. |
/admin/shard is the one mixed prefix, and it is mixed because it carries three different jobs:
- the self-service account-linking routes carry no gate beyond
staffOnly— they are the same handlers/player/shardserves, reached from the admin surface; - the in-game staff operations (kick, ban, unban, broadcast, pages) carry
modAccess(admin + moderator, so editors are excluded); GET/PUT /admin/shard/visibilityareadminOnly, a third tier abovemodAccess, because they decide what anonymous visitors can see (§4). A moderator can ban a player but cannot decide what the public internet reads.
Every admin write logs to core's one activity_log, through ctx.activity.log
(MODULE_API.md §2.7) — an admin action a module performs is not
allowed its own audit trail.
2. Public routes worth their own note
The full list is in the manifest and the merged spec. These are the ones that carried a design note
in core's contract before the extraction; the pre-3.0 ingest routes (/shard/status, /feed,
/online, /economy, /houses, /idoc, /champs, /guilds, /governors and the /stream SSE
feed) are described where their wire frames are, in
link/PLAN.md §5 and
link/INTEGRATION.md.
| Method | Path | Notes |
|---|---|---|
| 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/market?q=&minPrice=&maxPrice=&itemId=&map=®ion=&sort=&limit=&offset= |
search the player-vendor marketplace (Protocol 3.0 vendor.listing). Returns listings, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from shard_vendors + shard_vendor_items, so it renders while the shard is down. Behind requireFeature('market') and rate-limited — the first genuinely expensive public read on the site (a LIKE scan plus a COUNT over what is typically the largest shard_* table, reachable with no session). sort ∈ {price_asc, price_desc, recent}. q matches the resolved display name or the item's literal name, with %/_ escaped: they are LIKE metacharacters, not SQL ones, so parameterization alone would let ?q=% match every listing on the shard. Every response repeats staleAt (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | /shard/market/meta |
index size, staleness (staleAt/freshAt) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | /shard/market/vendors/:serial |
one shop and its listings; :serial must match /^0x[0-9A-Fa-f]{1,16}$/ or 400 before any query runs. 404 = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. truncated (with total exceeding count) means the shop holds more than the shard publishes per frame. |
| GET | /shard/features |
the shard features this caller may reach plus the audience rung they resolved to (§4 below), 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. |
3. Admin routes worth their own note
The two content imports — the spawn atlas and the cliloc table — whose behaviour is a decision
rather than a passthrough. The shard-ops routes (kick, ban, unban, broadcast, pages), the
account-linking routes and the sidecar config under /admin/uo-link are in the merged spec.
| Method | Path | Purpose |
|---|---|---|
| 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 500 — refresh() 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): every source found now (base first, then custom/ overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and missingSources. 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 or an overlay edit; {force} ignores the hash gate, {approve} accepts a vanished source (refused by default — see the table notes above). A missing path — 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 base file or directory (persisted as cliloc_client_path, which wins over UO_CLIENT_PATH). Overlays are read from custom/ beside it either way. Blank clears it. Deliberately does not import, same reasoning as the atlas path. |
4. 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:
acctandwebIdare 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 inacct/webId, case-insensitively, so the flattened forms the read models emit (shapeHouse→ownerAcct,shapeGuild→leaderWebId) are covered too. An exact-key check was the original implementation and it letGET /public/shard/idocserveownerAcctanonymously.- A kind absent from
KIND_FEATUREis never broadcast belowadmin. 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 (utils/shardBroadcast.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.