diff --git a/.env.example b/.env.example index 31c49cc..0739b00 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,8 @@ LOG_FILE=app.log # BRAND_NAME / BRAND_CONTACT_EMAIL. BRAND_NAME=Runic Gateway BRAND_SHORT_NAME=Runic Gateway -BRAND_TAGLINE=an independent private Ultima Online shard -BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes. +BRAND_TAGLINE=an independent game community +BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes. BRAND_CONTACT_EMAIL= BRAND_URL= # Accent color — drives the web theme's --accent and the Discord embed color. @@ -107,20 +107,18 @@ CLIENT_ORIGIN=http://localhost:5173 BOT_INTERNAL_URL=http://bot:4100 BOT_INTERNAL_KEY=change-me-to-a-long-random-string -# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The -# website ingests its live event feed and proxies its read queries/commands -# (shard status, online players, player-vendor sales, IDOC houses, character -# sheets, account linking, town-crier). In production the sidecar + shard run on -# a DIFFERENT host from the website, so both URLs are configurable. The -# shared-secret auth token is NOT an env var — it is entered in the admin panel -# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot -# token). These URLs are just defaults; the admin can override them at runtime. -UOLINK_BASE_URL=http://127.0.0.1:8080 -UOLINK_WS_URL=ws://127.0.0.1:8080/ws -# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site -# with nothing saved yet — the admin panel's pinned value wins — but set it lower -# if you deliberately run an older sidecar. -UOLINK_PROTOCOL=3 +# ─── Installed modules ─── +# A module is a directory on the modules volume (see MODULES_DIR in +# server/.env.example); everything about a specific game lives in one, and core +# knows nothing about any of them. A module may read its own env vars, and they +# belong here because Compose passes this file to the container. +# +# RunicGateway/Module-uo, for example, reads UOLINK_BASE_URL / UOLINK_WS_URL / +# UOLINK_PROTOCOL as the defaults for its connection to a uo-link sidecar, and +# TOWNCRIER_DURATION_SEC for its news leg. Its README documents them; they are +# left out here rather than half-copied, because a copy of another repo's +# settings is a copy that goes stale silently. With no module installed, none of +# this applies and the site runs as core. # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # The `ntfy` compose service and the backend's push fan-out (opt-in notifications diff --git a/README.md b/README.md index 93b8a15..0d21c09 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,19 @@ [![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) [![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) -Public site, wiki, and protected admin panel for a private Ultima Online shard — a -full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see -[Branding](#branding)); **UOMysticmoon** is the first instance. +Public site, wiki, and protected admin panel for a game community — a full-stack app +in one repo. Everything specific to a *particular* game lives in an installable +module, not here. Branding is instance-configurable via `BRAND_*` (see +[Branding](#branding)); **UOMysticmoon**, an Ultima Online shard, is the first +instance, and its game half is +[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo). A full-stack app in one repo: - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production. -- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). +- **Modules** — the game-specific half of a site is a module dropped onto a volume: it adds routes, database tables, nav entries and whole SPA pages without this repo knowing anything about the game. See [Modules](#modules). The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives. @@ -37,7 +40,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic - [Pages & routes](#pages--routes) - [API endpoints](#api-endpoints) - [API documentation (Swagger)](#api-documentation-swagger) -- [Shard integration (uo-link)](#shard-integration-uo-link) +- [Modules](#modules) - [Environment variables](#environment-variables) - [Security](#security) - [Logging](#logging) @@ -48,8 +51,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic ## Architecture How the pieces fit together — the React SPA and native app talk to one Express backend -(`router → controller → model → db`), which persists to MariaDB and bridges to the live -game world only through the **uo-link** sidecar. The shard itself is never internet-facing. +(`router → controller → model → db`), which persists to MariaDB. Anything that knows +what game this site is about lives in an installed module, on the right of the diagram. ```mermaid flowchart TB @@ -69,32 +72,29 @@ flowchart TB subgraph backend["server/ — Express backend"] direction TB mw["Middleware
helmet · siteMode · noindex
rateLimit · loginProtection · botScore · validate"] - router["Router /api/v1
auth (web · mobile · sso) · public · admin"] + router["Router /api/v1
auth (web · mobile · sso) · public · admin · player"] ctrl["Controllers"] auth["Session layer (auth/)
sessionService · JWT/cookie · bearer · SSO+PKCE"] model["Models (.model + .db)
raw parameterized SQL — no ORM"] sse["SSE fan-out
public stream (allowlist) · admin stream (sensitive)"] - - subgraph shardutil["Shard integration (utils/)"] - ingest["shardIngest.js
WS ingest dispatcher"] - restcli["uoLinkClient.js
REST client (never throws)"] - end - + loader["modules/loader.js
scans the volume · mounts · registries · lifecycle"] secret["secretBox.js
AES-256-GCM secrets at rest"] end bot["bot/
Discord bot"] end - db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
uoLinkConfig · shard_online/economy/houses/events")] + db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
installed_modules · <module>_*")] - %% ---------- Shard side ---------- - subgraph shardside["Game shard (never internet-facing)"] + %% ---------- Module side ---------- + subgraph modside["modules/<id>/  — installed, not built (e.g. Module-uo)"] direction TB - sidecar["uo-link sidecar
(Rust) — the only bridge exposed"] - servuo["ServUO shard
(C# plugin)"] + modsrv["server/ — routers, models, schema fragment
reaches core only through ctx"] + modcli["client/dist/entry.js — prebuilt ESM chunk
React shared via window.__rg"] end + game["The game
whatever the module talks to
(for Module-uo: a ServUO shard,
via the uo-link sidecar)"] + %% ---------- Edges ---------- browser <-->|"same-origin JSON + SSE (cookie)"| mw mobile -->|"REST (bearer access/refresh)"| mw @@ -104,40 +104,42 @@ flowchart TB mw --> router --> ctrl ctrl --> auth ctrl --> model - ctrl --> restcli ctrl --> sse auth --> model model <--> db auth -. reads/writes secrets .-> secret - restcli -. reads config/token .-> secret - ingest --> model - ingest --> sse sse -->|"live events"| browser bot -->|"messages"| discord bot <--> db - restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar - sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest - servuo -->|"loopback TCP 127.0.0.1:7788
newline-delimited JSON (shard dials out)"| sidecar + loader -->|"mounts under /api/v1/<tier>/<prefix>"| router + loader -->|"require() + register(ctx, api)"| modsrv + modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model + modsrv <--> game + browser -->|"<script type=module> injected by htmlShell"| modcli %% ---------- Styling ---------- classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0; classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea; - classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8; - class idp,discord ext; + classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8; + class idp,discord,game ext; class db store; - class sidecar,servuo bridge; + class modsrv,modcli mod; ``` - **One backend, layered.** Every request flows `middleware → router → controller → model → db`. Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. All three surfaces produce the *same* session via the session layer. -- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link - sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client - (`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down. -- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels — - a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events. +- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for + a specific game arrive from a module the operator installed. Core provides the seams; the module + fills them. See [Modules](#modules). +- **A module that fails must never take the site down.** The loader catches failures across a + module's whole lifecycle and marks that one module `startup_failed`; the site comes up with its + routes and nav absent, and the admin panel says why. +- **Sensitive events stay private.** Events fan out to browsers over two SSE channels — a public + allowlist stream and an admin-only stream that adds staff audit / cheat / login events. Which + event kinds are public is decided by the module that publishes them, and core enforces the split. --- @@ -164,12 +166,13 @@ website/ │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ app.js middleware + static SPA + routes │ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry) -│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups -│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db) +│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups +│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities · modules (.model + .db) +│ │ ├─ modules/ loader (scan · validate · mount) · registries (the seams) · lifecycle (boot/shutdown + reconcile) │ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate -│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger +│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell │ ├─ db/ schema.sql + seed.js -│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec) +│ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time) │ └─ .env.example ├─ client/ React + Vite SPA │ ├─ src/ @@ -178,9 +181,11 @@ website/ │ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors │ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), … │ │ ├─ contexts/ AuthContext, SiteContext +│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg │ │ ├─ api/client.js fetch wrapper (sends cookies) │ │ └─ styles/theme.css design tokens │ └─ public/assets/img/ hero image +├─ modules/ installed modules, one directory each — a Docker bind mount; empty here ├─ Dockerfile builds client → serves via Express ├─ docker-compose.yml app + MariaDB ├─ .env.example root env (used by Compose) @@ -306,7 +311,7 @@ npm start # node server → serves API + SPA at http://localhost:3 | `/site/screenshots` | Screenshot gallery | | `/site/five-on-friday` | Five on Friday | | `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue | -| `/site/about` · `/site/status` | About · Shard status | +| `/site/about` · `/site/status` | About · Site status | | `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) | **Admin** (cookie auth, `noindex`): @@ -324,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3 | `/admin/users` | User management | | `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) | +**Player** (any signed-in account, `noindex`): `/player` and its self-service views. Staff are a +superset of players and reach these too. + +An installed module adds its own pages under `//*`, `/admin//*` and `/player//*` — for +Module-uo that is `/uo/shard`, `/admin/uo/link`, `/player/uo/characters` and the rest. Core does not +know their names; they arrive with the module and are interleaved into the nav. + --- ## API endpoints @@ -335,9 +347,15 @@ npm start # node server → serves API + SPA at http://localhost:3 | SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | | Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) | -| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none | -| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) | -| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) | +| Player | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) | +| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none | + +**Module routes are not in this table**, because they are not core's. An installed module mounts +under `/api/v1/public/`, `/api/v1/admin/` and `/api/v1/player/`; which +prefixes exist depends on what is installed. Module-uo, for instance, serves 72 routes under +`/shard`, `/atlas` and `/uo-link` — see its own +[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json). +On a running instance, `/api/docs` lists everything, core and modules together. Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. `authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. @@ -380,6 +398,28 @@ npm run swagger # → server/swagger/swagger-output.json If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does not crash). +**The committed spec is core only, and the served one is not.** swagger-autogen is *static +analysis* — it parses `src/app.js` as text and follows the literal `app.use(…)` chain — so it can +see neither an installed module (which arrives on a volume long after the image was built, and +mounts through a call no parser can follow) nor an extension slot (whose router is created empty and +filled later). Both are handled by merging a **fragment**: + +- **Extension slots** contribute at generation time, from `server/swagger/slotSpecs.js`, so they are + in the committed file. +- **Modules** contribute at request time, from the `swagger-fragment.json` each one ships, merged by + `server/swagger/docsSpec.js`. So `/api/docs.json` on a running instance describes more than + `npm run swagger` produces here, and `swagger-output.json` stays reproducible on any machine + regardless of what is installed. + +**Core wins every key collision** — a module cannot redefine a core path, tag or schema by shipping +one with the same name; the collision is logged and the module's version dropped. + +One thing worth knowing if you edit an annotation: swagger-autogen **reports a broken one and then +succeeds anyway**, dropping it. `npm run swagger` now captures those diagnostics and fails, which is +how two annotations that had been silently documenting an empty request body were found. If it +rejects yours, the usual causes are an object literal a brace short, or a `"` or backtick inside a +single-quoted description (it re-quotes both to `'` before evaluating). + ### The route manifest (frozen URL surface) `server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two @@ -412,94 +452,75 @@ annotated routes appear), the manifest records reality. --- -## Shard integration (uo-link) +## Modules -The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that -runs next to the ServUO shard. Its source lives in a separate repo: -**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and -exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard -itself is never exposed to the internet — only the sidecar is, and only the website's backend talks -to it. +**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or +a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a +site for any game rather than for Ultima Online in particular. -### Setting up the shard side +The design of record is +[MODULE_SYSTEM.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md); +the normative contract — the one to read before writing a module — is +[MODULE_API.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md). +The worked example is [RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo), +which is where everything this README used to describe under *Shard integration (uo-link)* now +lives: the sidecar client, the ingest dispatcher, account linking, the town crier, the spawn atlas, +and every page that renders them. -You do not build or place any of it by hand. The -**[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer)** runs on the -shard host, deploys the ServUO plugin and the uo-link sidecar as a matched, protocol-checked pair, -registers the sidecar as a service, and ends by printing the four values this site needs: +### An operator never builds anything + +That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an +admin-panel action, or a directory dropped onto the `modules/` volume — because production runs a +prebuilt, pull-only image with no toolchain in it. So a module ships **assembled**: its client half +is a prebuilt ESM chunk that resolves React from a `window.__rg` global core owns (an import map +would have to be inline, and the CSP is `script-src 'self'`), and its one runtime dependency travels +inside the tarball. ``` -Base URL http://:8080 -WebSocket URL ws://:8080/ws -Protocol version 3 -Auth token 4f9c… +modules/ +└─ uo/ one directory per module; the id is the directory name + ├─ module.json id, version, coreApi range, mounts, extensions, capabilities + ├─ swagger-fragment.json merged into /api/docs.json while the module is running + ├─ server/ routers, models, and an idempotent schema.sql fragment + └─ client/dist/entry.js the prebuilt chunk, injected by utils/htmlShell.js ``` -Paste them into **Admin → Shard** here and the bridge is live. The operator guide is -[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md); -its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand) -is the same deployment done by hand, still supported, for a host that cannot run the binary or a -developer working from a source tree. +`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a +supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a +*missing* bind-mount source as `root:root`, and the container is uid 1000. -Nothing here needs the shard to exist: with no sidecar configured the site renders normally and -shows the shard offline. +### What a module gets, and what it may not do -### How it works +At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the +module's `register(ctx, api)`: + +- **`ctx` is everything core hands over** — the database, the logger, settings, the session reader, + push, the secret box, the middleware, the rate-limit factory, the activity log, and **express + itself**. A module lives outside `server/`, so Node's resolver never reaches core's + `node_modules`; anything it must share has to be handed to it, or there would be two Expresses and + two Reacts in one process. +- **`api` is everything it may register** — routes (one prefix per tier), an extension slot fill, + notification streams, a news-announce leg, a post hook, and `onBoot`/`onShutdown`. +- **It may not reach into core's tree**, mount outside its declared prefixes, or create tables + outside its `_` prefix. Each of those is checked, in the module's CI and again by the loader. + +Two things are guaranteed regardless of what a module does. **A failure never takes the site down**: +the loader catches everything from `require` to `onBoot`, marks that module `startup_failed`, and +the site comes up with its routes and nav absent and the reason on the admin screen. And **no URL of +core's may move** — a module that displaced one is caught by the frozen route manifest, which is +generated from a real core with the module loaded. + +### What is running right now ``` -ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser - REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE +GET /api/v1/public/modules +{ "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0", + "capabilities": ["shard", "atlas", "market", …] } ] } ``` -- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret - token, and protocol version are stored in the database (`uoLinkConfig`), edited from the - **Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in - the API — it is never returned to any client and never sent to the browser. Every call the backend - makes carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol - mismatch fails fast with `409` instead of being mis-parsed). -- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar - and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`, - `vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`, - `link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes - each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable - kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply - ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a - restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via - the sidecar's `/history`. -- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly — - `/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus - commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never - throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart - degrades to a `503`/retry banner instead of a 500. -- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**. - Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an - **admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login - attempts, IPs). Sensitive kinds can never leak onto the public channel. - -### Account linking - -A player (or staff member) proves ownership of a game account without sharing any game credentials: - -1. In game, the player runs **`[link`** and receives a one-time code. -2. On the website (Player portal, or Admin → Account for staff) they enter the code. -3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the - game account with the website user id, and mirrors the link locally in `shard_account_links`. - -That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints -are **ownership-checked** so a user only sees accounts they linked. **Admins may view any -character**; players and editor/moderator staff are limited to their own linked accounts. - -### What each audience sees - -| Surface | Endpoints | Who | Data | -|---|---|---|---| -| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. | -| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. | -| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). | - -The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the -shard is offline), every shard surface degrades gracefully — the public page still renders, showing -the shard as offline. +Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is +disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect +against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag). --- @@ -534,15 +555,14 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`. | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) | -| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) | -| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) | +| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for legs that are due or retrying. Which legs exist is up to what has registered one — Discord is core's; a module may add its own | --- ## Branding Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt -image can run as any shard. With none set, everything renders as **Runic Gateway**. +image can run as any community. With none set, everything renders as **Runic Gateway**. | Var | What | |---|---| diff --git a/server/.env.example b/server/.env.example index c6bdd04..2a1e208 100644 --- a/server/.env.example +++ b/server/.env.example @@ -99,13 +99,15 @@ CLIENT_ORIGIN=http://localhost:5173 BOT_INTERNAL_URL=http://localhost:4100 BOT_INTERNAL_KEY=dev-only-change-me-bot-key -# News announcement pipeline (published news post -> in-game town crier + Discord -# #news). The dispatcher is an in-process poller; these tune it. Links in the +# News announcement pipeline (published news post -> every registered delivery +# leg). The dispatcher is an in-process poller; this tunes it. Links in the # announcements use APP_BASE_URL (set above), so set that in production too. -# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs -# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400) +# +# Which legs exist depends on what has registered one: Discord (#news) is core's, +# and an installed module may add its own. A module's leg brings its own settings +# with it -- module-uo's in-game town crier reads TOWNCRIER_DURATION_SEC, which is +# documented in that module rather than here, because core has no town crier. ANNOUNCE_POLL_MS=15000 -TOWNCRIER_DURATION_SEC=3600 # Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted # ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs @@ -122,7 +124,7 @@ TOWNCRIER_DURATION_SEC=3600 # NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default). # Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint # (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL / -# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard. +# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for this instance. # NTFY_BASE_URL=https://ntfy.example.com # NTFY_PUBLIC_URL=https://ntfy.example.com # NTFY_ALLOWED_ORIGINS=https://ntfy.example.com diff --git a/server/src/app.js b/server/src/app.js index 8b9b8c9..f03a30a 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -111,15 +111,22 @@ app.use( ) // ── API docs (Swagger UI) ───────────────────────────────────────────── -// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec -// is generated from route annotations by `npm run swagger` (server/swagger/). +// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own +// routes are generated from their annotations by `npm run swagger` +// (server/swagger/) and committed; an installed module's routes cannot be — +// swagger-autogen is static analysis and a module arrives on the volume after the +// image was built — so each module ships its own fragment and they are merged +// HERE, per request, over core's committed spec (docs/website/MODULE_API.md §6.1a). // Loaded lazily and guarded so a missing spec never crashes the server. try { - // eslint-disable-next-line global-require + /* eslint-disable global-require */ const swaggerSpec = require('../swagger/swagger-output.json') + const { docsSpec } = require('../swagger/docsSpec') + /* eslint-enable global-require */ + app.get('/api/docs.json', (req, res) => { // #swagger.ignore = true - res.json(swaggerSpec) + res.json(docsSpec(swaggerSpec)) }) // swagger-ui-express injects an inline bootstrap script and inline styles, which // the global 'self'-only script-src would block — relax CSP for this route only. @@ -132,10 +139,18 @@ try { 'upgrade-insecure-requests': null, }, }) - app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + // `setup()` is called PER REQUEST rather than once here, because the document it + // renders is not fixed at boot: a module reaching `started` (or failing to) adds + // or removes paths, and a UI bound to the spec as it looked while app.js was + // still being required would show core's routes for the life of the process + // while /api/docs.json showed the merged set. `docsSpec` is cached on the + // loader's state version, so the repeated call costs a comparison. + const swaggerOpts = { customSiteTitle: `${brand.name} API docs`, swaggerOptions: { persistAuthorization: true }, - })) + } + app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) => + swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next)) } catch (err) { errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', { message: err.message, diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 61d3603..e986ac5 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -74,6 +74,13 @@ const MANIFEST_KEYS = new Set([ const modules = new Map() let loaded = false +// Bumped by every state CHANGE. One consumer today: the merged OpenAPI document +// at /api/docs.json, which is built from the fragments of `started` modules and +// so has to be rebuilt when that set moves (§6.1a). A counter rather than an +// event, because the question a cache asks is "is what I have still current", +// and a number answers it without anyone having to remember to subscribe. +let stateVersion = 0 + // ── ctx ──────────────────────────────────────────────────────────────────── // Everything a module may reach in core, and nothing else (§2.3). Required @@ -709,6 +716,7 @@ function setState(id, state, { stage = null, reason = null } = {}) { if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`) const record = modules.get(id) if (!record) return + if (record.state !== state) stateVersion += 1 record.state = state record.stage = state === 'startup_failed' ? stage : null record.reason = state === 'startup_failed' ? reason : null @@ -845,6 +853,39 @@ function clientEntryUrls() { .map((r) => r.client.entryUrl) } +/** + * Every started module's OpenAPI fragment, in scan order. + * + * `started` only, matching clientEntryUrls() rather than clientChunks(): the + * merged document is built when it is asked for, at which point the state is + * known, and documenting a module that is 503ing every one of those paths would + * send a client somewhere it cannot go. + * + * The filename is fixed by §2.8 — `swagger-fragment.json` in the bundle root — + * rather than declared in `module.json`, so a module cannot point core at + * something else. A module that ships none is simply absent: registering routes + * without documenting them is checked in the module's OWN CI (§2.8), where the + * routes are known; core has no way to tell the difference here between a module + * with no routes and one that forgot. + * + * @returns {{id: string, file: string}[]} + */ +function specFragments() { + assertLoaded('specFragments') + return [...modules.values()] + .filter((r) => r.state === 'started') + .map((r) => ({ id: r.id, file: path.join(r.dir, 'swagger-fragment.json') })) + .filter((f) => fs.existsSync(f.file)) +} + +/** + * How many times a module's state has CHANGED in this process. + * + * A cache key, and nothing more: hold the value you built with, compare, rebuild + * when it differs. It says nothing about which module moved or where to. + */ +const version = () => stateVersion + /** Absolute path of the modules directory. */ const dir = () => MODULES_DIR @@ -857,6 +898,8 @@ module.exports = { shutdownHooks, clientChunks, clientEntryUrls, + specFragments, + version, isLoaded, dir, } diff --git a/server/src/router/v1/admin/invites.router.js b/server/src/router/v1/admin/invites.router.js index d6c12da..5c49495 100644 --- a/server/src/router/v1/admin/invites.router.js +++ b/server/src/router/v1/admin/invites.router.js @@ -19,7 +19,7 @@ invitesRouter.post( // #swagger.tags = ['Admin · Invites'] // #swagger.summary = 'Create and email an account invite at a chosen access level' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } } */ /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ adminOnly, diff --git a/server/src/router/v1/auth/invite.router.js b/server/src/router/v1/auth/invite.router.js index f145de8..7a82a92 100644 --- a/server/src/router/v1/auth/invite.router.js +++ b/server/src/router/v1/auth/invite.router.js @@ -33,7 +33,7 @@ inviteRouter.post( // #swagger.tags = ['Auth'] // #swagger.summary = 'Accept an email invite (creates the account at the invited role)' // #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.' - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } } */ /* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */ /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/swagger/docsSpec.js b/server/swagger/docsSpec.js new file mode 100644 index 0000000..9f48b26 --- /dev/null +++ b/server/swagger/docsSpec.js @@ -0,0 +1,100 @@ +// ── The OpenAPI document core actually serves ────────────────────────────── +// +// `swagger-output.json` is core's own routes and only core's own routes: it is +// generated by `npm run swagger` on a developer's machine and committed, so it +// must come out the same regardless of which modules that developer happened to +// have checked out. A module's routes cannot be in it, and not merely because +// nobody put them there — a module arrives on a volume long after the image was +// built, and core never has its sources to analyse. +// +// So the document served at `/api/docs.json` is assembled at REQUEST time: core's +// committed spec, plus the `swagger-fragment.json` of every started module +// (docs/website/MODULE_API.md §2.8 and §6.1a). This file is that assembly. +// +// **Core always wins a key collision.** `mergeFragment` enforces it and reports +// what it dropped. A module cannot redefine a core path, tag or schema by shipping +// one with the same name — which is why §6.1a tells modules to namespace the +// schemas they define (`UoShardStatus`) while referencing core's shared ones +// (`Error`) by core's name: the first would collide and lose, the second resolves +// here, in the merged document, which is the only place both exist. +// +// **Cached, keyed on the loader's state version.** Building the document reads a +// file per module and deep-copies a 5,000-line spec; `/api/docs` is a page an +// operator opens occasionally and a crawler may hit repeatedly. The cache is +// invalidated by any module state CHANGE — which is what "started modules only" +// depends on, and the only input here that can move without a restart. + +const fs = require('fs') + +const modules = require('../src/modules/loader') +const createLogger = require('../src/utils/logger') +const { mergeFragment } = require('./mergeSpec') + +const log = createLogger('swagger') + +let cached = null +let cachedVersion = -1 + +/** + * Core's spec with every started module's fragment merged over it. + * + * Never throws: `/api/docs.json` answering with core's routes alone is a worse + * document than the full one, but it is a document. A fragment that is missing, + * unreadable or not JSON costs that module its paths and nothing else — the same + * bargain §4.4 makes everywhere else, where one module's failure is never the + * site's. + * + * @param {object} coreSpec the committed swagger-output.json — never mutated + * @returns {object} + */ +function docsSpec(coreSpec) { + // Before app.js has called modules.load(), asking is a mis-ordered boot rather + // than a core with nothing installed (§7.6) — but this is a request handler, and + // 500ing the docs page over it would be the wrong trade. Core's own spec is the + // honest answer to "what is documented" at that point anyway. + if (!modules.isLoaded()) return coreSpec + + const version = modules.version() + if (cached && cachedVersion === version) return cached + + // A structural copy, because mergeFragment writes into what it is given and + // `coreSpec` is a require()d JSON module: mutating it would make the merge + // cumulative across rebuilds and permanent for the life of the process. + const spec = JSON.parse(JSON.stringify(coreSpec)) + spec.paths = spec.paths || {} + spec.tags = spec.tags || [] + spec.components = spec.components || {} + spec.components.schemas = spec.components.schemas || {} + + for (const { id, file } of modules.specFragments()) { + let fragment + try { + fragment = JSON.parse(fs.readFileSync(file, 'utf8')) + } catch (err) { + log.warn('module OpenAPI fragment could not be read — its routes will be undocumented', { + module: id, + file, + error: err.message, + }) + continue + } + const before = Object.keys(spec.paths).length + mergeFragment(spec, fragment, `module ${id}`) + log.debug('merged module OpenAPI fragment', { + module: id, + paths: Object.keys(spec.paths).length - before, + }) + } + + cached = spec + cachedVersion = version + return spec +} + +/** Test seam: forget the cached document. */ +function reset() { + cached = null + cachedVersion = -1 +} + +module.exports = { docsSpec, reset } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 86f8189..dcafd53 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -3,7 +3,7 @@ "info": { "title": "Runic Gateway API", "version": "1.0.0", - "description": "REST API for the Runic Gateway website, wiki and admin panel — a private Ultima Online shard.\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." + "description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." }, "servers": [ { @@ -24,6 +24,10 @@ "name": "Auth", "description": "Web session login/logout (cookie + TOTP)" }, + { + "name": "Auth · Me", + "description": "The signed-in account: profile, notification streams and devices" + }, { "name": "Auth · Mobile", "description": "Native bearer-token login, refresh and logout" @@ -36,14 +40,6 @@ "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, - { - "name": "Public · Shard", - "description": "Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)" - }, - { - "name": "Public · Atlas", - "description": "Spawn atlas / bestiary — static shard content parsed from the shard's own ServUO tree, independent of the sidecar" - }, { "name": "Admin · Account", "description": "Self-service account security (2FA, linked identities)" @@ -52,10 +48,6 @@ "name": "Player", "description": "Self-service player accounts (register, credentials, 2FA, linked identities)" }, - { - "name": "Player · Shard", - "description": "Link an in-game account and read its roster / vendors (uo-link)" - }, { "name": "Player · Appeals", "description": "Player-submitted moderation appeals" @@ -72,6 +64,10 @@ "name": "Admin · Posts", "description": "News / five-on-friday / newsletter / screenshots + uploads" }, + { + "name": "Admin · Pages", + "description": "Editable static site pages" + }, { "name": "Admin · Wiki", "description": "Wiki pages, categories, tags and revisions" @@ -80,6 +76,18 @@ "name": "Admin · Settings", "description": "Site settings (admin only)" }, + { + "name": "Admin · Email", + "description": "Outbound email configuration and delivery test (admin only)" + }, + { + "name": "Admin · Invites", + "description": "Registration invites — issue, list and revoke" + }, + { + "name": "Admin · Moderation", + "description": "Player reports, appeals and moderator actions" + }, { "name": "Admin · Activity", "description": "Admin activity log" @@ -92,10 +100,6 @@ "name": "Admin · Discord Bot", "description": "Discord bot token/config and live status (admin only)" }, - { - "name": "Admin · Shard", - "description": "uo-link sidecar connection config, live status and town crier (admin only)" - }, { "name": "Admin · Auth Providers", "description": "SSO provider configuration (admin only)" @@ -1598,7 +1602,28 @@ "bearerAuth": [] } ], - "requestBody": {} + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "role" + ], + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "string" + } + } + } + } + } + } }, "get": { "tags": [ @@ -5657,7 +5682,28 @@ "description": "Internal Server Error" } }, - "requestBody": {} + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + } } }, "/api/v1/auth/login": { @@ -15198,4480 +15244,6 @@ } } } - }, - "ShardStatus": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Public shard status (GET /public/shard/status)." - }, - "properties": { - "type": "object", - "properties": { - "enabled": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "connected" - }, - "description": { - "type": "string", - "example": "connected | reconnecting | disconnected | error" - } - } - }, - "pluginConnected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "description": { - "type": "string", - "example": "Is the shard link up right now?" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "lastEventAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "onlineCount": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 12 - } - } - }, - "economy": { - "$ref": "#/components/schemas/ShardEconomyPoint" - } - } - } - } - }, - "ShardEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A logged shard event." - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 4821 - } - } - }, - "kind": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "vendor.sale" - } - } - }, - "t": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Event time, epoch ms." - }, - "example": { - "type": "number", - "example": 1783720195626 - } - } - }, - "bootId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "boot-abc123" - } - } - }, - "payload": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The full event object." - } - } - }, - "createdAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - } - } - } - } - }, - "ShardEconomyPoint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "One gold-supply sample." - }, - "properties": { - "type": "object", - "properties": { - "accounts": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 240 - } - } - }, - "gold": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1028983421 - } - } - }, - "t": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Sample time, epoch ms." - }, - "example": { - "type": "number", - "example": 1783720000000 - } - } - } - } - } - } - }, - "ShardOnlinePlayer": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A LINKED player online now (only accounts linked to a website user are listed)." - }, - "properties": { - "type": "object", - "properties": { - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x24C" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Darrow" - } - } - }, - "map": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Trammel" - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1402 - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1604 - } - } - }, - "z": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 0 - } - } - } - } - } - } - }, - "ShardVendorSale": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A player-vendor sale (visible only to the linked owner)." - }, - "properties": { - "type": "object", - "properties": { - "t": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Sale time, epoch ms." - }, - "example": { - "type": "number", - "example": 1783720195626 - } - } - }, - "itemType": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Longsword" - } - } - }, - "amount": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1 - } - } - }, - "price": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 100 - } - } - }, - "commission": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 5 - } - } - }, - "ownerAcct": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "whitlocktech" - } - } - } - } - } - } - }, - "ShardHouse": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A house at its current decay stage." - }, - "properties": { - "type": "object", - "properties": { - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x4004705F" - } - } - }, - "stage": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "IDOC" - } - } - }, - "map": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Trammel" - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "z": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "region": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "An Unnamed House" - } - } - }, - "ownerSerial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "ownerAcct": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "builtOn": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "lastRefreshed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "isIdoc": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "updatedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - } - } - } - } - }, - "ShardPointsBoard": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side." - }, - "properties": { - "type": "object", - "properties": { - "system": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "QueensLoyalty" - }, - "description": { - "type": "string", - "example": "The shard's PointsType name; the board's stable key." - } - } - }, - "nameString": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Queen's Loyalty" - } - } - }, - "nameNumber": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1114938 - }, - "description": { - "type": "string", - "example": "Cliloc id, 0 when the name is a literal." - } - } - }, - "maxPoints": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 30000 - } - } - }, - "players": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 842 - }, - "description": { - "type": "string", - "example": "Players actually holding points in this system." - } - } - }, - "showOnGump": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The shard's own 'is this player-facing?' flag." - } - } - }, - "top": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "rank": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1 - } - } - }, - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x1A2B" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Darrow" - }, - "description": { - "type": "string", - "example": "Omitted when the leaderboards `name` field is gated above the caller." - } - } - }, - "points": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 29500 - } - } - } - } - } - } - } - } - }, - "t": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Frame time, epoch ms." - } - } - }, - "updatedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - } - } - } - } - }, - "ShardMarketLocation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller." - }, - "properties": { - "type": "object", - "properties": { - "map": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Trammel" - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1421 - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1699 - } - } - }, - "z": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 0 - } - } - }, - "region": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Britain" - } - } - }, - "house": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow's Villa" - }, - "description": { - "type": "string", - "example": "The house SIGN's name, not the house type. Null for a vendor standing outside one." - } - } - } - } - } - } - }, - "ShardMarketListing": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request." - }, - "properties": { - "type": "object", - "properties": { - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x40012ABC" - } - } - }, - "itemId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 3922 - }, - "description": { - "type": "string", - "example": "ItemID (the art/graphic id)." - } - } - }, - "hue": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 0 - } - } - }, - "amount": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1 - } - } - }, - "price": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 25000 - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The item's own literal name, set by a player. Null for most items." - } - } - }, - "cliloc": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 1023721 - }, - "description": { - "type": "string", - "example": "The item's LabelNumber." - } - } - }, - "displayName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "quarter staff" - }, - "description": { - "type": "string", - "example": "Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id." - } - } - }, - "child": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": false - }, - "description": { - "type": "string", - "example": "Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it." - } - } - }, - "vendor": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x40001234" - } - } - }, - "shopName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow's Bargains" - } - } - }, - "ownerSerial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "0x1A2B" - }, - "description": { - "type": "string", - "example": "Omitted when the market `ownerSerial` field is gated above the caller." - } - } - }, - "ownerName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow" - }, - "description": { - "type": "string", - "example": "Omitted when the market `ownerName` field is gated above the caller." - } - } - }, - "location": { - "$ref": "#/components/schemas/ShardMarketLocation" - }, - "updatedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "description": { - "type": "string", - "example": "When the shard last published this shop." - } - } - } - } - } - } - } - } - } - } - }, - "ShardMarketPage": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A page of marketplace listings plus the unpaginated total and the staleness stamp." - }, - "properties": { - "type": "object", - "properties": { - "listings": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "$ref": "#/components/schemas/ShardMarketListing" - } - } - }, - "total": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1284 - }, - "description": { - "type": "string", - "example": "Matching listings, ignoring paging." - } - } - }, - "limit": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 50 - } - } - }, - "offset": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 0 - } - } - }, - "vendors": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 137 - }, - "description": { - "type": "string", - "example": "Vendors in the whole index." - } - } - }, - "staleAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices." - } - } - } - } - } - } - }, - "ShardMarketVendor": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "One player vendor and its listings." - }, - "properties": { - "type": "object", - "properties": { - "serial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "0x40001234" - } - } - }, - "shopName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow's Bargains" - } - } - }, - "ownerSerial": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "ownerName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow" - } - } - }, - "location": { - "$ref": "#/components/schemas/ShardMarketLocation" - }, - "count": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 250 - }, - "description": { - "type": "string", - "example": "Listings the shard published for this shop." - } - } - }, - "total": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 3104 - }, - "description": { - "type": "string", - "example": "Listings the shop actually holds." - } - } - }, - "truncated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "`total` exceeds `count` — the shop holds more than the shard publishes per frame." - } - } - }, - "updatedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "$ref": "#/components/schemas/ShardMarketListing" - } - } - } - } - } - } - }, - "ShardMarketMeta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Marketplace size, staleness and the filter options a client needs to build its UI." - }, - "properties": { - "type": "object", - "properties": { - "vendors": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 137 - } - } - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 18422 - } - } - }, - "staleAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "freshAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "maps": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "Felucca", - "Trammel" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": "string", - "example": "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." - } - } - }, - "regions": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "Britain", - "Luna" - ], - "items": { - "type": "string" - } - } - } - } - } - } - } - }, - "ShardFeatures": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403." - }, - "properties": { - "type": "object", - "properties": { - "level": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "anonymous", - "logged_in", - "player", - "staff", - "admin" - ], - "items": { - "type": "string" - } - }, - "example": { - "type": "string", - "example": "anonymous" - } - } - }, - "features": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "status", - "activity", - "champs", - "guilds", - "governors", - "houses", - "presence" - ], - "items": { - "type": "string" - } - } - } - } - } - } - } - }, - "ShardFeatureVisibility": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Visibility settings for one shard feature." - }, - "properties": { - "type": "object", - "properties": { - "enabled": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "audience": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "anonymous", - "logged_in", - "player", - "staff", - "admin" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": "string", - "example": "Minimum rung that may reach this feature. Each rung implies the ones below it." - }, - "example": { - "type": "string", - "example": "anonymous" - } - } - }, - "stream": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "description": { - "type": "string", - "example": "Whether this feature's event kinds fan out over SSE at all." - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "fieldRules": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "description": { - "type": "string", - "example": "Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here." - }, - "example": { - "type": "object", - "properties": { - "location": { - "type": "string", - "example": "staff" - } - } - } - } - } - } - } - } - }, - "ShardVisibilityConfig": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "ladder": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "anonymous", - "logged_in", - "player", - "staff", - "admin" - ], - "items": { - "type": "string" - } - } - } - }, - "lockedFields": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "acct", - "webId" - ], - "items": { - "type": "string" - } - } - } - }, - "defaults": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "$ref": "#/components/schemas/ShardFeatureVisibility" - } - } - }, - "features": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "$ref": "#/components/schemas/ShardFeatureVisibility" - } - } - } - } - } - } - }, - "ShardVisibilityUpdate": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "required": { - "type": "array", - "example": [ - "features" - ], - "items": { - "type": "string" - } - }, - "properties": { - "type": "object", - "properties": { - "features": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "$ref": "#/components/schemas/ShardFeatureVisibility" - }, - "example": { - "type": "object", - "properties": { - "market": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "example": true - }, - "audience": { - "type": "string", - "example": "player" - }, - "stream": { - "type": "boolean", - "example": false - }, - "fieldRules": { - "type": "object", - "properties": { - "ownerName": { - "type": "string", - "example": "player" - } - } - } - } - } - } - } - } - } - } - } - } - }, - "AtlasCreature": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route." - }, - "properties": { - "type": "object", - "properties": { - "slug": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "lizardman" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Lizardman" - } - } - }, - "total": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "How many can be alive at once, summed across every spawner." - }, - "example": { - "type": "number", - "example": 214 - } - } - }, - "points": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "How many spawners mention this creature." - }, - "example": { - "type": "number", - "example": 62 - } - } - }, - "facets": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "description": { - "type": "string", - "example": "This creature's share per facet." - }, - "example": { - "type": "object", - "properties": { - "Felucca": { - "type": "number", - "example": 96 - }, - "Trammel": { - "type": "number", - "example": 88 - }, - "Tokuno": { - "type": "number", - "example": 30 - } - } - } - } - }, - "art": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art." - } - } - }, - "places": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "Where it spawns, aggregated by resolved place. The answer the atlas exists to give." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "facet": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Trammel" - } - } - }, - "label": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "Resolved region, else nearest landmark group, else \"Wilderness\"." - }, - "example": { - "type": "string", - "example": "Shrines" - } - } - }, - "spawners": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 7 - } - } - }, - "maxAlive": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 21 - } - } - } - } - } - } - } - } - }, - "spawners": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "The individual spawners. Named separately from `points` (the count) so one key never means two things." - }, - "items": { - "$ref": "#/components/schemas/AtlasSpawner" - } - } - }, - "spawnersTruncated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "description": { - "type": "string", - "example": "True when the spawner list was cut at the requested bound." - }, - "example": { - "type": "boolean", - "example": false - } - } - }, - "alsoHere": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "Creatures sharing a spawner with this one." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "slug": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "lizardman-warrior" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Lizardman Warrior" - } - } - }, - "shared": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 12 - } - } - } - } - } - } - } - } - } - } - } - } - }, - "AtlasSpawner": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "One ServUO spawner, with the place its coordinates resolved to." - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "facet": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Felucca" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The spawner's own name in the ServUO file." - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 5411 - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1234 - } - } - }, - "width": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "height": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "range": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Spawn radius." - } - } - }, - "maxCount": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "How many of THIS creature this spawner keeps alive." - }, - "example": { - "type": "number", - "example": 3 - } - } - }, - "minDelay": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag." - }, - "example": { - "type": "number", - "example": 300 - } - } - }, - "maxDelay": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 600 - } - } - }, - "todStart": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Meaningless unless todMode is non-zero." - } - } - }, - "todEnd": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "todMode": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "region": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Despise" - } - } - }, - "landmark": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Covetous" - } - } - }, - "label": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "Region, else landmark group, else \"Wilderness\"." - }, - "example": { - "type": "string", - "example": "Despise" - } - } - } - } - } - } - }, - "AtlasCreaturePage": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "total": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Matching creatures before pagination." - }, - "example": { - "type": "number", - "example": 800 - } - } - }, - "limit": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 50 - } - } - }, - "offset": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 0 - } - } - }, - "creatures": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "$ref": "#/components/schemas/AtlasCreature" - } - } - } - } - } - } - }, - "AtlasRegion": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A named region, flattened out of the shard's nested Regions.xml." - }, - "properties": { - "type": "object", - "properties": { - "facet": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Felucca" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Despise" - } - } - }, - "type": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "ServUO region class." - }, - "example": { - "type": "string", - "example": "DungeonRegion" - } - } - }, - "priority": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 50 - } - } - }, - "parent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Britain" - } - } - }, - "rects": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "description": { - "type": "string", - "example": "The rectangles that placed each spawn point." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "additionalProperties": { - "type": "boolean", - "example": true - } - } - } - } - } - } - } - } - }, - "AtlasLandmark": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "facet": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Trammel" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Level 1" - } - } - }, - "group": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Innermost enclosing parent — the label worth showing." - }, - "example": { - "type": "string", - "example": "Covetous" - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 5411 - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1234 - } - } - }, - "z": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 0 - } - } - } - } - } - } - }, - "AtlasChampion": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that." - }, - "properties": { - "type": "object", - "properties": { - "slug": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "felucca-deceit" - } - } - }, - "name": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Deceit" - } - } - }, - "group": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Spawn group; one altar active per group." - }, - "example": { - "type": "string", - "example": "Dungeons" - } - } - }, - "type": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "NULL when the champion is drawn at activation." - }, - "example": { - "type": "string", - "example": "UnholyTerror" - } - } - }, - "randomType": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": false - } - } - }, - "facet": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "Felucca" - } - } - }, - "x": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "y": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "z": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "radius": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 60 - } - } - }, - "label": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Deceit" - } - } - } - } - } - } - }, - "AtlasMeta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route." - }, - "properties": { - "type": "object", - "properties": { - "importedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "generatedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "counts": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "additionalProperties": { - "type": "boolean", - "example": true - }, - "example": { - "type": "object", - "properties": { - "facets": { - "type": "number", - "example": 6 - }, - "points": { - "type": "number", - "example": 6455 - }, - "creatures": { - "type": "number", - "example": 800 - }, - "regions": { - "type": "number", - "example": 387 - }, - "landmarks": { - "type": "number", - "example": 558 - }, - "champions": { - "type": "number", - "example": 25 - }, - "unresolvedPoints": { - "type": "number", - "example": 1086 - } - } - } - } - }, - "facets": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "example": { - "type": "array", - "example": [ - "Felucca", - "Ilshenar", - "Malas", - "TerMur", - "Tokuno", - "Trammel" - ], - "items": { - "type": "string" - } - } - } - } - } - } - } - }, - "AtlasStatus": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review." - }, - "properties": { - "type": "object", - "properties": { - "configured": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "path": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "/srv/servuo" - } - } - }, - "treeReadable": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "drift": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "True when the tree's source hashes differ from the loaded atlas. NULL when the tree could not be read." - }, - "example": { - "type": "boolean", - "example": false - } - } - }, - "facets": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - } - } - }, - "importedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "counts": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "additionalProperties": { - "type": "boolean", - "example": true - } - } - }, - "pending": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected." - }, - "additionalProperties": { - "type": "boolean", - "example": true - } - } - } - } - } - } - }, - "AtlasRefreshResult": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500." - }, - "properties": { - "type": "object", - "properties": { - "status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "skipped", - "unavailable", - "unchanged", - "imported", - "needsReview", - "failed", - "rejected", - "none" - ], - "items": { - "type": "string" - } - }, - "example": { - "type": "string", - "example": "imported" - } - } - }, - "reason": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "path": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "counts": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "additionalProperties": { - "type": "boolean", - "example": true - } - } - }, - "addedFacets": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - } - } - }, - "removedFacets": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - } - } - } - } - } - } - }, - "ClilocStatus": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids." - }, - "properties": { - "type": "object", - "properties": { - "configured": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "path": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "/srv/uo-client" - } - } - }, - "file": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "The file actually resolved, when the path is a directory." - }, - "example": { - "type": "string", - "example": "/srv/uo-client/clilocs.tsv" - } - } - }, - "fileReadable": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "problem": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file." - }, - "example": {} - } - }, - "code": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Machine-readable cause of `problem`." - }, - "enum": { - "type": "array", - "example": [ - "NO_PATH", - "NOT_FOUND", - "NO_FILE", - "UNREADABLE", - "COMPRESSED" - ], - "items": { - "type": "string" - } - } - } - }, - "drift": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable." - }, - "example": { - "type": "boolean", - "example": false - } - } - }, - "count": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Entries currently loaded." - }, - "example": { - "type": "number", - "example": 67496 - } - } - }, - "sources": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "description": { - "type": "string", - "example": "Every source found now, root-relative, base first then overlays in merge order." - }, - "example": { - "type": "array", - "example": [ - "clilocs.plain", - "custom/uomysticmoon.tsv" - ], - "items": { - "type": "string" - } - } - } - }, - "loadedSources": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "What each source contributed at the last import." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "label": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "custom/uomysticmoon.tsv" - } - } - }, - "kind": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "base", - "custom" - ], - "items": { - "type": "string" - } - }, - "example": { - "type": "string", - "example": "custom" - } - } - }, - "entries": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 37 - } - } - }, - "added": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Ids this source introduced." - }, - "example": { - "type": "number", - "example": 25 - } - } - }, - "overrode": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "description": { - "type": "string", - "example": "Ids it replaced from an earlier source." - }, - "example": { - "type": "number", - "example": 12 - } - } - } - } - } - } - } - } - }, - "missingSources": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "description": { - "type": "string", - "example": "Sources loaded previously and now absent. An import refuses these without `approve`." - }, - "example": { - "type": "array", - "example": [], - "items": {} - } - } - }, - "importedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "sourceBytes": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 4973525 - } - } - } - } - } - } - }, - "ClilocRefreshResult": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500." - }, - "properties": { - "type": "object", - "properties": { - "status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "skipped", - "unavailable", - "unchanged", - "imported", - "needsReview", - "failed" - ], - "items": { - "type": "string" - } - }, - "description": { - "type": "string", - "example": "`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it." - }, - "example": { - "type": "string", - "example": "imported" - } - } - }, - "reason": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "code": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Machine-readable cause. `COMPRESSED` means the client's own Cliloc.enu was supplied instead of a converted one." - }, - "enum": { - "type": "array", - "example": [ - "NO_PATH", - "NOT_FOUND", - "NO_FILE", - "UNREADABLE", - "COMPRESSED", - "TRUNCATED", - "EMPTY", - "NOT_BUFFER" - ], - "items": { - "type": "string" - } - } - } - }, - "path": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "file": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - } - } - }, - "count": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Entries stored (blank strings are dropped)." - }, - "example": { - "type": "number", - "example": 67496 - } - } - }, - "parsed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Entries read across every source before blanks were dropped." - }, - "example": { - "type": "number", - "example": 123527 - } - } - }, - "blank": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "number", - "example": 55994 - } - } - }, - "sources": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "description": { - "type": "string", - "example": "Per-source breakdown: what each file contributed and how much of it overrode an earlier source." - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "label": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "kind": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "base", - "custom" - ], - "items": { - "type": "string" - } - } - } - }, - "entries": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "added": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - }, - "overrode": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - } - } - } - } - } - } - } - } - }, - "missingSources": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "description": { - "type": "string", - "example": "On `needsReview`: the sources that vanished. Nothing was applied." - } - } - }, - "acceptedMissing": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - } - } - }, - "description": { - "type": "string", - "example": "On `imported` with `approve`: the vanished sources the admin accepted." - } - } - } - } - } - } - }, - "ShardLinkRequest": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "required": { - "type": "array", - "example": [ - "code" - ], - "items": { - "type": "string" - } - }, - "properties": { - "type": "object", - "properties": { - "code": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "description": { - "type": "string", - "example": "The one-time code shown by [link in game." - }, - "example": { - "type": "string", - "example": "AB12CD" - } - } - } - } - } - } - }, - "ShardLinkResult": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "properties": { - "type": "object", - "properties": { - "linked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - }, - "account": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "whitlocktech" - } - } - } - } - } - } - }, - "ShardLink": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "A linked in-game account (GET /player/shard/accounts)." - }, - "properties": { - "type": "object", - "properties": { - "account": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "whitlocktech" - } - } - }, - "userId": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 42 - } - } - }, - "charName": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "nullable": { - "type": "boolean", - "example": true - }, - "example": { - "type": "string", - "example": "Darrow" - } - } - }, - "linkedAt": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "format": { - "type": "string", - "example": "date-time" - } - } - } - } - } - } - }, - "TownCrierRequest": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "required": { - "type": "array", - "example": [ - "id", - "lines" - ], - "items": { - "type": "string" - } - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "maxLength": { - "type": "number", - "example": 64 - }, - "description": { - "type": "string", - "example": "Re-posting the same id replaces the prior entry." - }, - "example": { - "type": "string", - "example": "news-42" - } - } - }, - "lines": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "array" - }, - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "maxLength": { - "type": "number", - "example": 200 - } - } - }, - "example": { - "type": "array", - "example": [ - "Hear ye!", - "Market tax is now 5%." - ], - "items": { - "type": "string" - } - } - } - }, - "durationSec": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "minimum": { - "type": "number", - "example": 1 - }, - "maximum": { - "type": "number", - "example": 86400 - }, - "example": { - "type": "number", - "example": 3600 - } - } - } - } - } - } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 9718f65..91197a9 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -33,8 +33,11 @@ const doc = { title: `${brand.name} API`, version: pkg.version, description: - `REST API for the ${brand.name} website, wiki and admin panel — a private ` + - 'Ultima Online shard.\n\n' + + `REST API for the ${brand.name} website, wiki and admin panel.\n\n` + + 'This document is core. Installed modules add their own paths, tags and ' + + 'schemas to it at request time from the fragment each one ships, so ' + + '`/api/docs.json` on a running instance describes more than `npm run swagger` ' + + 'generates here (docs/website/MODULE_API.md §6.1a).\n\n' + '### Authentication\n' + `- **Web / admin panel** uses an httpOnly session cookie (\`${COOKIE_NAME}\`) issued by ` + '`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' + @@ -47,27 +50,33 @@ const doc = { { url: '/', description: 'Same-origin (current host)' }, { url: 'http://localhost:3000', description: 'Local development' }, ], + // Core's tags only. A module contributes its own in its fragment, and they are + // merged in beside these — the four game-specific ones that used to sit here + // (`Public · Shard`, `Public · Atlas`, `Player · Shard`, `Admin · Shard`) went + // with the routes they group, and arrive back from module-uo on any instance + // that has it installed. tags: [ { name: 'Health', description: 'Liveness probe' }, { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' }, + { name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' }, { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, - { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, - { name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, - { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' }, { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, + { name: 'Admin · Pages', description: 'Editable static site pages' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, { name: 'Admin · Settings', description: 'Site settings (admin only)' }, + { name: 'Admin · Email', description: 'Outbound email configuration and delivery test (admin only)' }, + { name: 'Admin · Invites', description: 'Registration invites — issue, list and revoke' }, + { name: 'Admin · Moderation', description: 'Player reports, appeals and moderator actions' }, { name: 'Admin · Activity', description: 'Admin activity log' }, { name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' }, { name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' }, - { name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' }, { name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' }, { name: 'Admin · Users', description: 'User management (admin only)' }, ], @@ -918,584 +927,6 @@ const doc = { removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, - // ── uo-link shard data ────────────────────────────────────────────── - ShardStatus: { - type: 'object', - description: 'Public shard status (GET /public/shard/status).', - properties: { - enabled: { type: 'boolean', example: true }, - status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' }, - pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true }, - lastEventAt: { type: 'string', format: 'date-time', nullable: true }, - onlineCount: { type: 'integer', example: 12 }, - economy: { $ref: '#/components/schemas/ShardEconomyPoint' }, - }, - }, - ShardEvent: { - type: 'object', - description: 'A logged shard event.', - properties: { - id: { type: 'integer', example: 4821 }, - kind: { type: 'string', example: 'vendor.sale' }, - t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 }, - bootId: { type: 'string', nullable: true, example: 'boot-abc123' }, - payload: { type: 'object', additionalProperties: true, description: 'The full event object.' }, - createdAt: { type: 'string', format: 'date-time' }, - }, - }, - ShardEconomyPoint: { - type: 'object', - nullable: true, - description: 'One gold-supply sample.', - properties: { - accounts: { type: 'integer', nullable: true, example: 240 }, - gold: { type: 'integer', nullable: true, example: 1028983421 }, - t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 }, - }, - }, - ShardOnlinePlayer: { - type: 'object', - description: 'A LINKED player online now (only accounts linked to a website user are listed).', - properties: { - serial: { type: 'string', example: '0x24C' }, - name: { type: 'string', example: 'Darrow' }, - map: { type: 'string', nullable: true, example: 'Trammel' }, - x: { type: 'integer', nullable: true, example: 1402 }, - y: { type: 'integer', nullable: true, example: 1604 }, - z: { type: 'integer', nullable: true, example: 0 }, - }, - }, - ShardVendorSale: { - type: 'object', - description: 'A player-vendor sale (visible only to the linked owner).', - properties: { - t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 }, - itemType: { type: 'string', example: 'Longsword' }, - amount: { type: 'integer', example: 1 }, - price: { type: 'integer', example: 100 }, - commission: { type: 'integer', nullable: true, example: 5 }, - ownerAcct: { type: 'string', example: 'whitlocktech' }, - }, - }, - ShardHouse: { - type: 'object', - description: 'A house at its current decay stage.', - properties: { - serial: { type: 'string', example: '0x4004705F' }, - stage: { type: 'string', example: 'IDOC' }, - map: { type: 'string', nullable: true, example: 'Trammel' }, - x: { type: 'integer', nullable: true }, - y: { type: 'integer', nullable: true }, - z: { type: 'integer', nullable: true }, - region: { type: 'string', nullable: true }, - name: { type: 'string', nullable: true, example: 'An Unnamed House' }, - ownerSerial: { type: 'string', nullable: true }, - ownerAcct: { type: 'string', nullable: true }, - builtOn: { type: 'string', format: 'date-time', nullable: true }, - lastRefreshed: { type: 'string', format: 'date-time', nullable: true }, - isIdoc: { type: 'boolean', example: true }, - updatedAt: { type: 'string', format: 'date-time' }, - }, - }, - ShardPointsBoard: { - type: 'object', - description: - "One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.", - properties: { - system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." }, - nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" }, - nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' }, - maxPoints: { type: 'integer', nullable: true, example: 30000 }, - players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' }, - showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." }, - top: { - type: 'array', - description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.', - items: { - type: 'object', - properties: { - rank: { type: 'integer', example: 1 }, - serial: { type: 'string', example: '0x1A2B' }, - name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' }, - points: { type: 'integer', example: 29500 }, - }, - }, - }, - t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' }, - updatedAt: { type: 'string', format: 'date-time' }, - }, - }, - ShardMarketLocation: { - type: 'object', - nullable: true, - description: - "Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.", - properties: { - map: { type: 'string', nullable: true, example: 'Trammel' }, - x: { type: 'integer', nullable: true, example: 1421 }, - y: { type: 'integer', nullable: true, example: 1699 }, - z: { type: 'integer', nullable: true, example: 0 }, - region: { type: 'string', nullable: true, example: 'Britain' }, - house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." }, - }, - }, - ShardMarketListing: { - type: 'object', - description: - 'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.', - properties: { - serial: { type: 'string', example: '0x40012ABC' }, - itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' }, - hue: { type: 'integer', example: 0 }, - amount: { type: 'integer', example: 1 }, - price: { type: 'integer', example: 25000 }, - name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." }, - cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." }, - displayName: { - type: 'string', - nullable: true, - example: 'quarter staff', - description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.', - }, - child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' }, - vendor: { - type: 'object', - properties: { - serial: { type: 'string', example: '0x40001234' }, - shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" }, - ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' }, - ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' }, - location: { $ref: '#/components/schemas/ShardMarketLocation' }, - updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' }, - }, - }, - }, - }, - ShardMarketPage: { - type: 'object', - description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.', - properties: { - listings: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } }, - total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' }, - limit: { type: 'integer', example: 50 }, - offset: { type: 'integer', example: 0 }, - vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' }, - staleAt: { - type: 'string', - format: 'date-time', - nullable: true, - description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.', - }, - }, - }, - ShardMarketVendor: { - type: 'object', - description: 'One player vendor and its listings.', - properties: { - serial: { type: 'string', example: '0x40001234' }, - shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" }, - ownerSerial: { type: 'string', nullable: true }, - ownerName: { type: 'string', nullable: true, example: 'Darrow' }, - location: { $ref: '#/components/schemas/ShardMarketLocation' }, - count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' }, - total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' }, - truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' }, - updatedAt: { type: 'string', format: 'date-time' }, - items: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } }, - }, - }, - ShardMarketMeta: { - type: 'object', - description: 'Marketplace size, staleness and the filter options a client needs to build its UI.', - properties: { - vendors: { type: 'integer', example: 137 }, - items: { type: 'integer', example: 18422 }, - staleAt: { type: 'string', format: 'date-time', nullable: true }, - freshAt: { type: 'string', format: 'date-time', nullable: true }, - maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." }, - regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] }, - }, - }, - ShardFeatures: { - type: 'object', - description: - "The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.", - properties: { - level: { - type: 'string', - enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], - example: 'anonymous', - }, - features: { - type: 'array', - items: { type: 'string' }, - example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'], - }, - }, - }, - ShardFeatureVisibility: { - type: 'object', - description: 'Visibility settings for one shard feature.', - properties: { - enabled: { type: 'boolean', example: true }, - audience: { - type: 'string', - enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], - description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.', - example: 'anonymous', - }, - stream: { - type: 'boolean', - description: "Whether this feature's event kinds fan out over SSE at all.", - example: true, - }, - fieldRules: { - type: 'object', - additionalProperties: { type: 'string' }, - description: - 'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.', - example: { location: 'staff' }, - }, - }, - }, - ShardVisibilityConfig: { - type: 'object', - properties: { - ladder: { - type: 'array', - items: { type: 'string' }, - example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], - }, - lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] }, - defaults: { - type: 'object', - additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' }, - }, - features: { - type: 'object', - additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' }, - }, - }, - }, - ShardVisibilityUpdate: { - type: 'object', - required: ['features'], - properties: { - features: { - type: 'object', - additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' }, - example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } }, - }, - }, - }, - // ── Spawn atlas (Protocol 3.0 Part C) ──────────────────────────────── - // Static shard content, parsed from the shard's own ServUO tree. Nothing - // here comes from the sidecar, so it stays populated while the shard is - // down. Facet names are whatever the shard's files declare — the examples - // below are stock ServUO, not a fixed list. - AtlasCreature: { - type: 'object', - description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.', - properties: { - slug: { type: 'string', example: 'lizardman' }, - name: { type: 'string', example: 'Lizardman' }, - total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 }, - points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 }, - facets: { - type: 'object', - additionalProperties: { type: 'integer' }, - description: "This creature's share per facet.", - example: { Felucca: 96, Trammel: 88, Tokuno: 30 }, - }, - art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' }, - places: { - type: 'array', - description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.', - items: { - type: 'object', - properties: { - facet: { type: 'string', example: 'Trammel' }, - label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' }, - spawners: { type: 'integer', example: 7 }, - maxAlive: { type: 'integer', example: 21 }, - }, - }, - }, - spawners: { - type: 'array', - description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.', - items: { $ref: '#/components/schemas/AtlasSpawner' }, - }, - spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false }, - alsoHere: { - type: 'array', - description: 'Creatures sharing a spawner with this one.', - items: { - type: 'object', - properties: { - slug: { type: 'string', example: 'lizardman-warrior' }, - name: { type: 'string', example: 'Lizardman Warrior' }, - shared: { type: 'integer', example: 12 }, - }, - }, - }, - }, - }, - AtlasSpawner: { - type: 'object', - description: 'One ServUO spawner, with the place its coordinates resolved to.', - properties: { - id: { type: 'integer' }, - facet: { type: 'string', example: 'Felucca' }, - name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." }, - x: { type: 'integer', example: 5411 }, - y: { type: 'integer', example: 1234 }, - width: { type: 'integer' }, - height: { type: 'integer' }, - range: { type: 'integer', description: 'Spawn radius.' }, - maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 }, - minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 }, - maxDelay: { type: 'integer', example: 600 }, - todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' }, - todEnd: { type: 'integer' }, - todMode: { type: 'integer' }, - region: { type: 'string', nullable: true, example: 'Despise' }, - landmark: { type: 'string', nullable: true, example: 'Covetous' }, - label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' }, - }, - }, - AtlasCreaturePage: { - type: 'object', - properties: { - total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 }, - limit: { type: 'integer', example: 50 }, - offset: { type: 'integer', example: 0 }, - creatures: { type: 'array', items: { $ref: '#/components/schemas/AtlasCreature' } }, - }, - }, - AtlasRegion: { - type: 'object', - description: 'A named region, flattened out of the shard\'s nested Regions.xml.', - properties: { - facet: { type: 'string', example: 'Felucca' }, - name: { type: 'string', example: 'Despise' }, - type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' }, - priority: { type: 'integer', example: 50 }, - parent: { type: 'string', nullable: true, example: 'Britain' }, - rects: { - type: 'array', - description: 'The rectangles that placed each spawn point.', - items: { type: 'object', additionalProperties: true }, - }, - }, - }, - AtlasLandmark: { - type: 'object', - properties: { - facet: { type: 'string', example: 'Trammel' }, - name: { type: 'string', example: 'Level 1' }, - group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' }, - x: { type: 'integer', example: 5411 }, - y: { type: 'integer', example: 1234 }, - z: { type: 'integer', example: 0 }, - }, - }, - AtlasChampion: { - type: 'object', - description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.', - properties: { - slug: { type: 'string', example: 'felucca-deceit' }, - name: { type: 'string', example: 'Deceit' }, - group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' }, - type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' }, - randomType: { type: 'boolean', example: false }, - facet: { type: 'string', example: 'Felucca' }, - x: { type: 'integer' }, - y: { type: 'integer' }, - z: { type: 'integer' }, - radius: { type: 'integer', example: 60 }, - label: { type: 'string', nullable: true, example: 'Deceit' }, - }, - }, - AtlasMeta: { - type: 'object', - description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.', - properties: { - importedAt: { type: 'string', format: 'date-time', nullable: true }, - generatedAt: { type: 'string', format: 'date-time', nullable: true }, - counts: { - type: 'object', - nullable: true, - additionalProperties: true, - example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 }, - }, - facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] }, - }, - }, - AtlasStatus: { - type: 'object', - description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.', - properties: { - configured: { type: 'boolean', example: true }, - path: { type: 'string', example: '/srv/servuo' }, - treeReadable: { type: 'boolean', example: true }, - drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false }, - facets: { type: 'array', items: { type: 'string' } }, - importedAt: { type: 'string', format: 'date-time', nullable: true }, - counts: { type: 'object', nullable: true, additionalProperties: true }, - pending: { - type: 'object', - nullable: true, - description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.', - additionalProperties: true, - }, - }, - }, - AtlasRefreshResult: { - type: 'object', - description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.', - properties: { - status: { - type: 'string', - enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'], - example: 'imported', - }, - reason: { type: 'string', nullable: true }, - path: { type: 'string', nullable: true }, - counts: { type: 'object', nullable: true, additionalProperties: true }, - addedFacets: { type: 'array', items: { type: 'string' } }, - removedFacets: { type: 'array', items: { type: 'string' } }, - }, - }, - ClilocStatus: { - type: 'object', - description: - 'Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids.', - properties: { - configured: { type: 'boolean', example: true }, - path: { type: 'string', example: '/srv/uo-client' }, - file: { type: 'string', nullable: true, description: 'The file actually resolved, when the path is a directory.', example: '/srv/uo-client/clilocs.tsv' }, - fileReadable: { type: 'boolean', example: true }, - problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null }, - code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] }, - drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false }, - count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 }, - sources: { - type: 'array', - items: { type: 'string' }, - description: 'Every source found now, root-relative, base first then overlays in merge order.', - example: ['clilocs.plain', 'custom/uomysticmoon.tsv'], - }, - loadedSources: { - type: 'array', - nullable: true, - description: 'What each source contributed at the last import.', - items: { - type: 'object', - properties: { - label: { type: 'string', example: 'custom/uomysticmoon.tsv' }, - kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' }, - entries: { type: 'integer', example: 37 }, - added: { type: 'integer', description: 'Ids this source introduced.', example: 25 }, - overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 }, - }, - }, - }, - missingSources: { - type: 'array', - items: { type: 'string' }, - description: 'Sources loaded previously and now absent. An import refuses these without `approve`.', - example: [], - }, - importedAt: { type: 'string', format: 'date-time', nullable: true }, - sourceBytes: { type: 'integer', nullable: true, example: 4973525 }, - }, - }, - ClilocRefreshResult: { - type: 'object', - description: - 'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.', - properties: { - status: { - type: 'string', - enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'], - description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.', - example: 'imported', - }, - reason: { type: 'string', nullable: true }, - code: { - type: 'string', - nullable: true, - description: 'Machine-readable cause. `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.', - enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER'], - }, - path: { type: 'string', nullable: true }, - file: { type: 'string', nullable: true }, - count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 }, - parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 }, - blank: { type: 'integer', nullable: true, example: 55994 }, - sources: { - type: 'array', - nullable: true, - description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.', - items: { - type: 'object', - properties: { - label: { type: 'string' }, - kind: { type: 'string', enum: ['base', 'custom'] }, - entries: { type: 'integer' }, - added: { type: 'integer' }, - overrode: { type: 'integer' }, - }, - }, - }, - missingSources: { - type: 'array', - nullable: true, - items: { type: 'string' }, - description: 'On `needsReview`: the sources that vanished. Nothing was applied.', - }, - acceptedMissing: { - type: 'array', - nullable: true, - items: { type: 'string' }, - description: 'On `imported` with `approve`: the vanished sources the admin accepted.', - }, - }, - }, - ShardLinkRequest: { - type: 'object', - required: ['code'], - properties: { - code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' }, - }, - }, - ShardLinkResult: { - type: 'object', - properties: { - linked: { type: 'boolean', example: true }, - account: { type: 'string', example: 'whitlocktech' }, - }, - }, - ShardLink: { - type: 'object', - description: 'A linked in-game account (GET /player/shard/accounts).', - properties: { - account: { type: 'string', example: 'whitlocktech' }, - userId: { type: 'integer', example: 42 }, - charName: { type: 'string', nullable: true, example: 'Darrow' }, - linkedAt: { type: 'string', format: 'date-time' }, - }, - }, - TownCrierRequest: { - type: 'object', - required: ['id', 'lines'], - properties: { - id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' }, - lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] }, - durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 }, - }, - }, }, }, } @@ -1549,8 +980,37 @@ function normalizePaths(spec) { process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1' process.env.DB_PORT = process.env.DB_PORT || '59999' +// ── An annotation swagger-autogen cannot parse is DROPPED, not failed ────── +// +// It `console.error`s "Syntax error" or "out of structure", skips that one +// annotation, and prints `Success` in green. Nothing was listening, so the tree +// had been carrying a broken one — `POST /api/v1/admin/invites` documented with an +// EMPTY request body — for as long as it had existed. The same class turned up +// four more times in module-uo, whose annotations came from here. +// +// Two ways one breaks, both of them invisible in review: an object literal a +// brace short, and a `"` or a backtick inside a single-quoted description +// (swagger-autogen re-quotes both to `'` before evaluating, which ends the string +// early). Capturing the diagnostics is the only way to be told. +const swaggerComplaints = [] +const realConsoleError = console.error +console.error = (...args) => { + const line = args.map(String).join(' ') + if (/syntax error|out of structure/i.test(line)) swaggerComplaints.push(line.trim()) + else realConsoleError(...args) +} + /* eslint-disable global-require */ swaggerAutogen(outputFile, routes, doc) + .then(() => { + console.error = realConsoleError + if (swaggerComplaints.length > 0) { + throw new Error( + `swagger: ${swaggerComplaints.length} annotation(s) could not be parsed and were DROPPED ` + + `— the spec would be missing what they described:\n ${swaggerComplaints.join('\n ')}`, + ) + } + }) .then(() => require('./slotSpecs').mergeSlotSpecs(outputFile)) .then(() => { const written = JSON.parse(fs.readFileSync(outputFile, 'utf8')) @@ -1561,6 +1021,7 @@ swaggerAutogen(outputFile, routes, doc) return require('../src/utils/db').close() }) .catch((err) => { + console.error = realConsoleError process.stderr.write(`${err.stack || err.message}\n`) process.exit(1) }) diff --git a/server/test/moduleDocsSpec.test.js b/server/test/moduleDocsSpec.test.js new file mode 100644 index 0000000..c894c12 --- /dev/null +++ b/server/test/moduleDocsSpec.test.js @@ -0,0 +1,205 @@ +// ── /api/docs.json, with a module installed ──────────────────────────────── +// +// The request-time half of docs/website/MODULE_API.md §6.1's settled decision. +// `swagger-output.json` is core's own routes and cannot be anything else: it is +// generated on a developer's machine and committed, so it has to come out the +// same regardless of what they had checked out, and a module arrives on the +// volume long after the image was built. The module's routes therefore reach the +// document only here, from the fragment it ships (§2.8, §6.1a). +// +// This boots the REAL app against a throwaway module directory, because the two +// things worth locking are properties of the served document rather than of the +// merge helper: that a started module's paths are IN it, and that core wins. +// +// The modules directory is written and MODULES_DIR set BEFORE app.js is required +// — the scan is synchronous and happens during that require. + +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { test, before, after } = require('node:test') +const assert = require('node:assert/strict') + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-docs-')) +const moduleDir = path.join(tmpRoot, 'atlas') +fs.mkdirSync(path.join(moduleDir, 'client', 'dist'), { recursive: true }) +fs.writeFileSync(path.join(moduleDir, 'client', 'dist', 'entry.js'), 'export const hello = 1\n') +fs.writeFileSync( + path.join(moduleDir, 'module.json'), + JSON.stringify({ + id: 'atlas', + name: 'Atlas', + version: '1.0.0', + coreApi: '^1.0.0', + client: { entry: 'client/dist/entry.js' }, + }), +) +fs.writeFileSync( + path.join(moduleDir, 'swagger-fragment.json'), + JSON.stringify({ + paths: { + '/api/v1/public/atlas/creatures': { get: { tags: ['Public · Atlas'], summary: 'List creatures' } }, + // The collision case, and the one that matters: a module trying to + // redefine a path core already declares. Core wins and the module's + // version is dropped (§6.1a) — a module cannot rewrite core's docs. + '/api/v1/public/settings': { get: { summary: 'MODULE OVERRIDE' } }, + }, + tags: [{ name: 'Public · Atlas', description: 'from the module' }], + components: { + schemas: { + AtlasCreature: { type: 'object' }, + // Same shape of collision, one section down. + Error: { type: 'string', description: 'MODULE OVERRIDE' }, + }, + }, + }), +) +process.env.MODULES_DIR = tmpRoot + +/* eslint-disable global-require */ +const app = require('../src/app') +const loader = require('../src/modules/loader') +const db = require('../src/utils/db') +const coreSpec = require('../swagger/swagger-output.json') +const { docsSpec, reset } = require('../swagger/docsSpec') +/* eslint-enable global-require */ + +let server +let base + +before(async () => { + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + base = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + await db.close() + fs.rmSync(tmpRoot, { recursive: true, force: true }) +}) + +const fetchSpec = async () => { + const res = await fetch(`${base}/api/docs.json`) + assert.equal(res.status, 200) + return res.json() +} + +test('a module that is not started contributes nothing', async () => { + // It is `registered` here: loaded cleanly, onBoot not yet dispatched. Its + // routes answer 503 in that state, so documenting them would send a client + // somewhere it cannot go — the same reason the client chunk's