feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5) #141

Merged
whitlocktech merged 1 commits from feature/module-openapi-merge into edge 2026-08-12 04:12:10 +00:00
11 changed files with 643 additions and 5227 deletions
Showing only changes of commit adff20be7b - Show all commits

View File

@@ -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

260
README.md
View File

@@ -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<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin · player"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>installed_modules · &lt;module&gt;_*")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
%% ---------- Module side ----------
subgraph modside["modules/&lt;id&gt;/ &nbsp;— installed, not built (e.g. Module-uo)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
end
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>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<br/>newline-delimited JSON (shard dials out)"| sidecar
loader -->|"mounts under /api/v1/&lt;tier&gt;/&lt;prefix&gt;"| router
loader -->|"require() + register(ctx, api)"| modsrv
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
modsrv <--> game
browser -->|"&lt;script type=module&gt; 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 `/<id>/*`, `/admin/<id>/*` and `/player/<id>/*` — 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/<prefix>`, `/api/v1/admin/<prefix>` and `/api/v1/player/<prefix>`; 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://<shard-host>:8080
WebSocket URL ws://<shard-host>: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 `<id>_` 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 <token>` 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` / `<server>/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 |
|---|---|

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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 invites 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" } } } } */

100
server/swagger/docsSpec.js Normal file
View File

@@ -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 }

File diff suppressed because it is too large Load Diff

View File

@@ -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)
})

View File

@@ -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 <script> tag is
// withheld until `started`.
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
})
test('a started module\'s paths, tags and schemas are in the served document', async () => {
loader.setState('atlas', 'started')
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'].get.summary, 'List creatures')
assert.ok(spec.tags.some((t) => t.name === 'Public · Atlas'))
assert.deepEqual(spec.components.schemas.AtlasCreature, { type: 'object' })
})
test('core wins every collision, in every section', async () => {
loader.setState('atlas', 'started')
const spec = await fetchSpec()
assert.notEqual(spec.paths['/api/v1/public/settings'].get.summary, 'MODULE OVERRIDE')
assert.notEqual(spec.components.schemas.Error.description, 'MODULE OVERRIDE')
assert.deepEqual(spec.components.schemas.Error, coreSpec.components.schemas.Error)
})
test('the committed spec is never mutated by the merge', async () => {
// `swagger-output.json` is a require()d JSON module, so one in-place merge
// would be permanent for the life of the process AND cumulative across
// rebuilds — a module's paths surviving its own uninstall.
loader.setState('atlas', 'started')
await fetchSpec()
assert.equal(coreSpec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.equal(coreSpec.components.schemas.AtlasCreature, undefined)
})
test('a state change rebuilds the document rather than serving the cached one', async () => {
loader.setState('atlas', 'started')
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
loader.setState('atlas', 'disabled')
assert.equal((await fetchSpec()).paths['/api/v1/public/atlas/creatures'], undefined)
loader.setState('atlas', 'started')
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
})
test('an unreadable fragment costs that module its paths and nothing else', async () => {
// §4.4's bargain, applied here: one module's failure is never the site's. A
// docs page that 500s is strictly worse than one missing a module's routes.
loader.setState('atlas', 'started')
const file = path.join(moduleDir, 'swagger-fragment.json')
const good = fs.readFileSync(file, 'utf8')
fs.writeFileSync(file, 'not json {')
try {
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.ok(spec.paths['/api/v1/public/settings'], 'core\'s own paths must survive')
} finally {
fs.writeFileSync(file, good)
reset()
}
})
test('a module with no fragment at all is simply absent', async () => {
// Registering routes without documenting them is checked in the MODULE's CI,
// where the routes are known. Core cannot tell a module with no routes from
// one that forgot, so it does not guess.
loader.setState('atlas', 'started')
const file = path.join(moduleDir, 'swagger-fragment.json')
const good = fs.readFileSync(file, 'utf8')
fs.rmSync(file)
try {
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.ok(Object.keys(spec.paths).length > 100)
} finally {
fs.writeFileSync(file, good)
reset()
}
})
test('before the loader has scanned, core\'s own spec is the answer', () => {
// §7.6 makes every other accessor THROW when asked before modules.load(), so a
// mis-ordered boot cannot be mistaken for an empty install. A request handler
// is the exception: 500ing the docs page over it would be the wrong trade, and
// core's routes are the honest answer to "what is documented" at that point.
reset()
const stub = { isLoaded: () => false }
const original = Object.getOwnPropertyDescriptor(loader, 'isLoaded')
Object.defineProperty(loader, 'isLoaded', { value: stub.isLoaded, configurable: true })
try {
assert.equal(docsSpec(coreSpec), coreSpec)
} finally {
Object.defineProperty(loader, 'isLoaded', original)
reset()
}
})
test('the interactive UI is rebuilt per request, not bound to boot\'s document', async () => {
// Bound once at require time, /api/docs would show core's routes for the life
// of the process while /api/docs.json showed the merged set.
loader.setState('atlas', 'started')
reset()
const withModule = await fetch(`${base}/api/docs/`)
assert.equal(withModule.status, 200)
assert.match(await withModule.text(), /swagger/i)
})