Merge pull request 'feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)' (#141) from feature/module-openapi-merge into edge
Reviewed-on: #141
This commit is contained in:
30
.env.example
30
.env.example
@@ -33,8 +33,8 @@ LOG_FILE=app.log
|
|||||||
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
# BRAND_NAME / BRAND_CONTACT_EMAIL.
|
||||||
BRAND_NAME=Runic Gateway
|
BRAND_NAME=Runic Gateway
|
||||||
BRAND_SHORT_NAME=Runic Gateway
|
BRAND_SHORT_NAME=Runic Gateway
|
||||||
BRAND_TAGLINE=an independent private Ultima Online shard
|
BRAND_TAGLINE=an independent game community
|
||||||
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes.
|
BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes.
|
||||||
BRAND_CONTACT_EMAIL=
|
BRAND_CONTACT_EMAIL=
|
||||||
BRAND_URL=
|
BRAND_URL=
|
||||||
# Accent color — drives the web theme's --accent and the Discord embed color.
|
# 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_URL=http://bot:4100
|
||||||
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The
|
# ─── Installed modules ───
|
||||||
# website ingests its live event feed and proxies its read queries/commands
|
# A module is a directory on the modules volume (see MODULES_DIR in
|
||||||
# (shard status, online players, player-vendor sales, IDOC houses, character
|
# server/.env.example); everything about a specific game lives in one, and core
|
||||||
# sheets, account linking, town-crier). In production the sidecar + shard run on
|
# knows nothing about any of them. A module may read its own env vars, and they
|
||||||
# a DIFFERENT host from the website, so both URLs are configurable. The
|
# belong here because Compose passes this file to the container.
|
||||||
# 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
|
# RunicGateway/Module-uo, for example, reads UOLINK_BASE_URL / UOLINK_WS_URL /
|
||||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
# UOLINK_PROTOCOL as the defaults for its connection to a uo-link sidecar, and
|
||||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
# TOWNCRIER_DURATION_SEC for its news leg. Its README documents them; they are
|
||||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
# left out here rather than half-copied, because a copy of another repo's
|
||||||
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
|
# settings is a copy that goes stale silently. With no module installed, none of
|
||||||
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
|
# this applies and the site runs as core.
|
||||||
# if you deliberately run an older sidecar.
|
|
||||||
UOLINK_PROTOCOL=3
|
|
||||||
|
|
||||||
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||||
|
|||||||
260
README.md
260
README.md
@@ -8,16 +8,19 @@
|
|||||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
|
||||||
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
Public site, wiki, and protected admin panel for a game community — a full-stack app
|
||||||
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
in one repo. Everything specific to a *particular* game lives in an installable
|
||||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
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:
|
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).
|
- **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).
|
- **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.
|
- **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.
|
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)
|
- [Pages & routes](#pages--routes)
|
||||||
- [API endpoints](#api-endpoints)
|
- [API endpoints](#api-endpoints)
|
||||||
- [API documentation (Swagger)](#api-documentation-swagger)
|
- [API documentation (Swagger)](#api-documentation-swagger)
|
||||||
- [Shard integration (uo-link)](#shard-integration-uo-link)
|
- [Modules](#modules)
|
||||||
- [Environment variables](#environment-variables)
|
- [Environment variables](#environment-variables)
|
||||||
- [Security](#security)
|
- [Security](#security)
|
||||||
- [Logging](#logging)
|
- [Logging](#logging)
|
||||||
@@ -48,8 +51,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
How the pieces fit together — the React SPA and native app talk to one Express backend
|
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
|
(`router → controller → model → db`), which persists to MariaDB. Anything that knows
|
||||||
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
|
what game this site is about lives in an installed module, on the right of the diagram.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
@@ -69,32 +72,29 @@ flowchart TB
|
|||||||
subgraph backend["server/ — Express backend"]
|
subgraph backend["server/ — Express backend"]
|
||||||
direction TB
|
direction TB
|
||||||
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
|
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"]
|
ctrl["Controllers"]
|
||||||
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
|
||||||
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
|
||||||
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
|
||||||
|
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
|
||||||
subgraph shardutil["Shard integration (utils/)"]
|
|
||||||
ingest["shardIngest.js<br/>WS ingest dispatcher"]
|
|
||||||
restcli["uoLinkClient.js<br/>REST client (never throws)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
|
||||||
end
|
end
|
||||||
|
|
||||||
bot["bot/<br/>Discord bot"]
|
bot["bot/<br/>Discord bot"]
|
||||||
end
|
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 · <module>_*")]
|
||||||
|
|
||||||
%% ---------- Shard side ----------
|
%% ---------- Module side ----------
|
||||||
subgraph shardside["Game shard (never internet-facing)"]
|
subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
|
||||||
direction TB
|
direction TB
|
||||||
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
|
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
|
||||||
servuo["ServUO shard<br/>(C# plugin)"]
|
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]
|
||||||
|
|
||||||
%% ---------- Edges ----------
|
%% ---------- Edges ----------
|
||||||
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
browser <-->|"same-origin JSON + SSE (cookie)"| mw
|
||||||
mobile -->|"REST (bearer access/refresh)"| mw
|
mobile -->|"REST (bearer access/refresh)"| mw
|
||||||
@@ -104,40 +104,42 @@ flowchart TB
|
|||||||
mw --> router --> ctrl
|
mw --> router --> ctrl
|
||||||
ctrl --> auth
|
ctrl --> auth
|
||||||
ctrl --> model
|
ctrl --> model
|
||||||
ctrl --> restcli
|
|
||||||
ctrl --> sse
|
ctrl --> sse
|
||||||
auth --> model
|
auth --> model
|
||||||
model <--> db
|
model <--> db
|
||||||
auth -. reads/writes secrets .-> secret
|
auth -. reads/writes secrets .-> secret
|
||||||
restcli -. reads config/token .-> secret
|
|
||||||
ingest --> model
|
|
||||||
ingest --> sse
|
|
||||||
sse -->|"live events"| browser
|
sse -->|"live events"| browser
|
||||||
bot -->|"messages"| discord
|
bot -->|"messages"| discord
|
||||||
bot <--> db
|
bot <--> db
|
||||||
|
|
||||||
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
|
loader -->|"mounts under /api/v1/<tier>/<prefix>"| router
|
||||||
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
|
loader -->|"require() + register(ctx, api)"| modsrv
|
||||||
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
|
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
|
||||||
|
modsrv <--> game
|
||||||
|
browser -->|"<script type=module> injected by htmlShell"| modcli
|
||||||
|
|
||||||
%% ---------- Styling ----------
|
%% ---------- Styling ----------
|
||||||
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
|
||||||
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
|
||||||
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
|
||||||
class idp,discord ext;
|
class idp,discord,game ext;
|
||||||
class db store;
|
class db store;
|
||||||
class sidecar,servuo bridge;
|
class modsrv,modcli mod;
|
||||||
```
|
```
|
||||||
|
|
||||||
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
|
- **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
|
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.
|
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.
|
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
|
- **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for
|
||||||
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
|
a specific game arrive from a module the operator installed. Core provides the seams; the module
|
||||||
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
|
fills them. See [Modules](#modules).
|
||||||
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels —
|
- **A module that fails must never take the site down.** The loader catches failures across a
|
||||||
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events.
|
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)
|
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
|
||||||
│ │ ├─ app.js middleware + static SPA + routes
|
│ │ ├─ 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)
|
│ │ ├─ 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
|
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups
|
||||||
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db)
|
│ │ ├─ 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
|
│ │ ├─ 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
|
│ ├─ 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
|
│ └─ .env.example
|
||||||
├─ client/ React + Vite SPA
|
├─ client/ React + Vite SPA
|
||||||
│ ├─ src/
|
│ ├─ 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
|
│ │ ├─ 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), …
|
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
|
||||||
│ │ ├─ contexts/ AuthContext, SiteContext
|
│ │ ├─ contexts/ AuthContext, SiteContext
|
||||||
|
│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg
|
||||||
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
│ │ ├─ api/client.js fetch wrapper (sends cookies)
|
||||||
│ │ └─ styles/theme.css design tokens
|
│ │ └─ styles/theme.css design tokens
|
||||||
│ └─ public/assets/img/ hero image
|
│ └─ public/assets/img/ hero image
|
||||||
|
├─ modules/ installed modules, one directory each — a Docker bind mount; empty here
|
||||||
├─ Dockerfile builds client → serves via Express
|
├─ Dockerfile builds client → serves via Express
|
||||||
├─ docker-compose.yml app + MariaDB
|
├─ docker-compose.yml app + MariaDB
|
||||||
├─ .env.example root env (used by Compose)
|
├─ .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/screenshots` | Screenshot gallery |
|
||||||
| `/site/five-on-friday` | Five on Friday |
|
| `/site/five-on-friday` | Five on Friday |
|
||||||
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
|
| `/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) |
|
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
|
||||||
|
|
||||||
**Admin** (cookie auth, `noindex`):
|
**Admin** (cookie auth, `noindex`):
|
||||||
@@ -324,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3
|
|||||||
| `/admin/users` | User management |
|
| `/admin/users` | User management |
|
||||||
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
|
| `/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
|
## 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 |
|
| 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 |
|
| 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) |
|
| 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 | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) |
|
||||||
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) |
|
| Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none |
|
||||||
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
|
|
||||||
|
**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`.
|
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
|
||||||
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
|
`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
|
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
|
||||||
not crash).
|
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)
|
### The route manifest (frozen URL surface)
|
||||||
|
|
||||||
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
|
`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
|
**Everything specific to a game is a module.** Core has no idea what an "account", a "character" or
|
||||||
runs next to the ServUO shard. Its source lives in a separate repo:
|
a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a
|
||||||
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and
|
site for any game rather than for Ultima Online in particular.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
### An operator never builds anything
|
||||||
**[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,
|
That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an
|
||||||
registers the sidecar as a service, and ends by printing the four values this site needs:
|
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
|
modules/
|
||||||
WebSocket URL ws://<shard-host>:8080/ws
|
└─ uo/ one directory per module; the id is the directory name
|
||||||
Protocol version 3
|
├─ module.json id, version, coreApi range, mounts, extensions, capabilities
|
||||||
Auth token 4f9c…
|
├─ 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
|
`modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a
|
||||||
[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md);
|
supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a
|
||||||
its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
*missing* bind-mount source as `root:root`, and the container is uid 1000.
|
||||||
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.
|
|
||||||
|
|
||||||
Nothing here needs the shard to exist: with no sidecar configured the site renders normally and
|
### What a module gets, and what it may not do
|
||||||
shows the shard offline.
|
|
||||||
|
|
||||||
### 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
|
GET /api/v1/public/modules
|
||||||
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE
|
{ "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
|
Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is
|
||||||
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the
|
disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect
|
||||||
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in
|
against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag).
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 |
|
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
|
||||||
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
|
| `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) |
|
| `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) |
|
| `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 |
|
||||||
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Branding
|
## Branding
|
||||||
|
|
||||||
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
|
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 |
|
| Var | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -99,13 +99,15 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
BOT_INTERNAL_URL=http://localhost:4100
|
BOT_INTERNAL_URL=http://localhost:4100
|
||||||
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
|
||||||
|
|
||||||
# News announcement pipeline (published news post -> in-game town crier + Discord
|
# News announcement pipeline (published news post -> every registered delivery
|
||||||
# #news). The dispatcher is an in-process poller; these tune it. Links in the
|
# 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.
|
# 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
|
ANNOUNCE_POLL_MS=15000
|
||||||
TOWNCRIER_DURATION_SEC=3600
|
|
||||||
|
|
||||||
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
|
# 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
|
# 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).
|
# 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
|
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
|
||||||
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
|
# (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_BASE_URL=https://ntfy.example.com
|
||||||
# NTFY_PUBLIC_URL=https://ntfy.example.com
|
# NTFY_PUBLIC_URL=https://ntfy.example.com
|
||||||
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
|
||||||
|
|||||||
@@ -111,15 +111,22 @@ app.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own
|
||||||
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
// 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.
|
// Loaded lazily and guarded so a missing spec never crashes the server.
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line global-require
|
/* eslint-disable global-require */
|
||||||
const swaggerSpec = require('../swagger/swagger-output.json')
|
const swaggerSpec = require('../swagger/swagger-output.json')
|
||||||
|
const { docsSpec } = require('../swagger/docsSpec')
|
||||||
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
app.get('/api/docs.json', (req, res) => {
|
app.get('/api/docs.json', (req, res) => {
|
||||||
// #swagger.ignore = true
|
// #swagger.ignore = true
|
||||||
res.json(swaggerSpec)
|
res.json(docsSpec(swaggerSpec))
|
||||||
})
|
})
|
||||||
// swagger-ui-express injects an inline bootstrap script and inline styles, which
|
// 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.
|
// the global 'self'-only script-src would block — relax CSP for this route only.
|
||||||
@@ -132,10 +139,18 @@ try {
|
|||||||
'upgrade-insecure-requests': null,
|
'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`,
|
customSiteTitle: `${brand.name} API docs`,
|
||||||
swaggerOptions: { persistAuthorization: true },
|
swaggerOptions: { persistAuthorization: true },
|
||||||
}))
|
}
|
||||||
|
app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) =>
|
||||||
|
swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
||||||
message: err.message,
|
message: err.message,
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ const MANIFEST_KEYS = new Set([
|
|||||||
const modules = new Map()
|
const modules = new Map()
|
||||||
let loaded = false
|
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 ────────────────────────────────────────────────────────────────────
|
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Everything a module may reach in core, and nothing else (§2.3). Required
|
// 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}"`)
|
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
|
||||||
const record = modules.get(id)
|
const record = modules.get(id)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
|
if (record.state !== state) stateVersion += 1
|
||||||
record.state = state
|
record.state = state
|
||||||
record.stage = state === 'startup_failed' ? stage : null
|
record.stage = state === 'startup_failed' ? stage : null
|
||||||
record.reason = state === 'startup_failed' ? reason : null
|
record.reason = state === 'startup_failed' ? reason : null
|
||||||
@@ -845,6 +853,39 @@ function clientEntryUrls() {
|
|||||||
.map((r) => r.client.entryUrl)
|
.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. */
|
/** Absolute path of the modules directory. */
|
||||||
const dir = () => MODULES_DIR
|
const dir = () => MODULES_DIR
|
||||||
|
|
||||||
@@ -857,6 +898,8 @@ module.exports = {
|
|||||||
shutdownHooks,
|
shutdownHooks,
|
||||||
clientChunks,
|
clientChunks,
|
||||||
clientEntryUrls,
|
clientEntryUrls,
|
||||||
|
specFragments,
|
||||||
|
version,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
dir,
|
dir,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ invitesRouter.post(
|
|||||||
// #swagger.tags = ['Admin · Invites']
|
// #swagger.tags = ['Admin · Invites']
|
||||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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[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" } } } } */
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ inviteRouter.post(
|
|||||||
// #swagger.tags = ['Auth']
|
// #swagger.tags = ['Auth']
|
||||||
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
|
// #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.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[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[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" } } } } */
|
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
|||||||
100
server/swagger/docsSpec.js
Normal file
100
server/swagger/docsSpec.js
Normal 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
@@ -33,8 +33,11 @@ const doc = {
|
|||||||
title: `${brand.name} API`,
|
title: `${brand.name} API`,
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
description:
|
description:
|
||||||
`REST API for the ${brand.name} website, wiki and admin panel — a private ` +
|
`REST API for the ${brand.name} website, wiki and admin panel.\n\n` +
|
||||||
'Ultima Online shard.\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' +
|
'### Authentication\n' +
|
||||||
`- **Web / admin panel** uses an httpOnly session cookie (\`${COOKIE_NAME}\`) issued by ` +
|
`- **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' +
|
'`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: '/', description: 'Same-origin (current host)' },
|
||||||
{ url: 'http://localhost:3000', description: 'Local development' },
|
{ 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: [
|
tags: [
|
||||||
{ name: 'Health', description: 'Liveness probe' },
|
{ name: 'Health', description: 'Liveness probe' },
|
||||||
{ name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
|
{ 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 · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
||||||
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
||||||
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
{ 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: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 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: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
|
||||||
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
|
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
|
||||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
{ 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 · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||||
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
{ 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 · Activity', description: 'Admin activity log' },
|
||||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
{ 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 · 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 · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||||
{ name: 'Admin · Users', description: 'User management (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 },
|
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_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
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 */
|
/* eslint-disable global-require */
|
||||||
swaggerAutogen(outputFile, routes, doc)
|
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(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||||
@@ -1561,6 +1021,7 @@ swaggerAutogen(outputFile, routes, doc)
|
|||||||
return require('../src/utils/db').close()
|
return require('../src/utils/db').close()
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
console.error = realConsoleError
|
||||||
process.stderr.write(`${err.stack || err.message}\n`)
|
process.stderr.write(`${err.stack || err.message}\n`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
205
server/test/moduleDocsSpec.test.js
Normal file
205
server/test/moduleDocsSpec.test.js
Normal 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)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user