# 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`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json) and documented operation-by-operation in its [`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/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`](../../website/BACKEND_DESIGN.md) §4 and §6.5 when Phase 4 closed ([`MODULE_SYSTEM.md`](../../website/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/shard` serves, 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/visibility` are `adminOnly`**, a third tier above `modAccess`, 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`](../../website/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`](../../link/PLAN.md) §5 and [`link/INTEGRATION.md`](../../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`](../../website/SHARD_VISIBILITY.md). Design: [`link/v3.md`](../../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 (`shapeHouse` → `ownerAcct`, `shapeGuild` → `leaderWebId`) 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 (`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.