feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.
**The merge (MODULE_API.md §6.1a).** `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 must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.
Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.
Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.
**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):
- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
not served since slice 1 — 578 lines. They moved to module-uo, namespaced
`Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
`## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
module, not by core, and documented in the module's README instead.
**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.
Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.
- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
(128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
/api/docs renders the module's operations with zero console errors
Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,8 +33,11 @@ const doc = {
|
||||
title: `${brand.name} API`,
|
||||
version: pkg.version,
|
||||
description:
|
||||
`REST API for the ${brand.name} website, wiki and admin panel — a private ` +
|
||||
'Ultima Online shard.\n\n' +
|
||||
`REST API for the ${brand.name} website, wiki and admin panel.\n\n` +
|
||||
'This document is core. Installed modules add their own paths, tags and ' +
|
||||
'schemas to it at request time from the fragment each one ships, so ' +
|
||||
'`/api/docs.json` on a running instance describes more than `npm run swagger` ' +
|
||||
'generates here (docs/website/MODULE_API.md §6.1a).\n\n' +
|
||||
'### Authentication\n' +
|
||||
`- **Web / admin panel** uses an httpOnly session cookie (\`${COOKIE_NAME}\`) issued by ` +
|
||||
'`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' +
|
||||
@@ -47,27 +50,33 @@ const doc = {
|
||||
{ url: '/', description: 'Same-origin (current host)' },
|
||||
{ url: 'http://localhost:3000', description: 'Local development' },
|
||||
],
|
||||
// Core's tags only. A module contributes its own in its fragment, and they are
|
||||
// merged in beside these — the four game-specific ones that used to sit here
|
||||
// (`Public · Shard`, `Public · Atlas`, `Player · Shard`, `Admin · Shard`) went
|
||||
// with the routes they group, and arrive back from module-uo on any instance
|
||||
// that has it installed.
|
||||
tags: [
|
||||
{ name: 'Health', description: 'Liveness probe' },
|
||||
{ name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' },
|
||||
{ name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' },
|
||||
{ name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' },
|
||||
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
|
||||
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
|
||||
{ name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' },
|
||||
{ name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' },
|
||||
{ name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' },
|
||||
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
|
||||
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
||||
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
|
||||
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
|
||||
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
|
||||
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
|
||||
{ name: 'Admin · Pages', description: 'Editable static site pages' },
|
||||
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
|
||||
{ name: 'Admin · Settings', description: 'Site settings (admin only)' },
|
||||
{ name: 'Admin · Email', description: 'Outbound email configuration and delivery test (admin only)' },
|
||||
{ name: 'Admin · Invites', description: 'Registration invites — issue, list and revoke' },
|
||||
{ name: 'Admin · Moderation', description: 'Player reports, appeals and moderator actions' },
|
||||
{ name: 'Admin · Activity', description: 'Admin activity log' },
|
||||
{ name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' },
|
||||
{ name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' },
|
||||
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
|
||||
{ name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' },
|
||||
{ name: 'Admin · Users', description: 'User management (admin only)' },
|
||||
],
|
||||
@@ -918,584 +927,6 @@ const doc = {
|
||||
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
|
||||
},
|
||||
},
|
||||
// ── uo-link shard data ──────────────────────────────────────────────
|
||||
ShardStatus: {
|
||||
type: 'object',
|
||||
description: 'Public shard status (GET /public/shard/status).',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', example: true },
|
||||
status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' },
|
||||
pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true },
|
||||
lastEventAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
onlineCount: { type: 'integer', example: 12 },
|
||||
economy: { $ref: '#/components/schemas/ShardEconomyPoint' },
|
||||
},
|
||||
},
|
||||
ShardEvent: {
|
||||
type: 'object',
|
||||
description: 'A logged shard event.',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 4821 },
|
||||
kind: { type: 'string', example: 'vendor.sale' },
|
||||
t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 },
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-abc123' },
|
||||
payload: { type: 'object', additionalProperties: true, description: 'The full event object.' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ShardEconomyPoint: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'One gold-supply sample.',
|
||||
properties: {
|
||||
accounts: { type: 'integer', nullable: true, example: 240 },
|
||||
gold: { type: 'integer', nullable: true, example: 1028983421 },
|
||||
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
||||
},
|
||||
},
|
||||
ShardOnlinePlayer: {
|
||||
type: 'object',
|
||||
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x24C' },
|
||||
name: { type: 'string', example: 'Darrow' },
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true, example: 1402 },
|
||||
y: { type: 'integer', nullable: true, example: 1604 },
|
||||
z: { type: 'integer', nullable: true, example: 0 },
|
||||
},
|
||||
},
|
||||
ShardVendorSale: {
|
||||
type: 'object',
|
||||
description: 'A player-vendor sale (visible only to the linked owner).',
|
||||
properties: {
|
||||
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
|
||||
itemType: { type: 'string', example: 'Longsword' },
|
||||
amount: { type: 'integer', example: 1 },
|
||||
price: { type: 'integer', example: 100 },
|
||||
commission: { type: 'integer', nullable: true, example: 5 },
|
||||
ownerAcct: { type: 'string', example: 'whitlocktech' },
|
||||
},
|
||||
},
|
||||
ShardHouse: {
|
||||
type: 'object',
|
||||
description: 'A house at its current decay stage.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x4004705F' },
|
||||
stage: { type: 'string', example: 'IDOC' },
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true },
|
||||
y: { type: 'integer', nullable: true },
|
||||
z: { type: 'integer', nullable: true },
|
||||
region: { type: 'string', nullable: true },
|
||||
name: { type: 'string', nullable: true, example: 'An Unnamed House' },
|
||||
ownerSerial: { type: 'string', nullable: true },
|
||||
ownerAcct: { type: 'string', nullable: true },
|
||||
builtOn: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastRefreshed: { type: 'string', format: 'date-time', nullable: true },
|
||||
isIdoc: { type: 'boolean', example: true },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ShardPointsBoard: {
|
||||
type: 'object',
|
||||
description:
|
||||
"One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.",
|
||||
properties: {
|
||||
system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." },
|
||||
nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" },
|
||||
nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' },
|
||||
maxPoints: { type: 'integer', nullable: true, example: 30000 },
|
||||
players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' },
|
||||
showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." },
|
||||
top: {
|
||||
type: 'array',
|
||||
description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rank: { type: 'integer', example: 1 },
|
||||
serial: { type: 'string', example: '0x1A2B' },
|
||||
name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' },
|
||||
points: { type: 'integer', example: 29500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ShardMarketLocation: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
"Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.",
|
||||
properties: {
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true, example: 1421 },
|
||||
y: { type: 'integer', nullable: true, example: 1699 },
|
||||
z: { type: 'integer', nullable: true, example: 0 },
|
||||
region: { type: 'string', nullable: true, example: 'Britain' },
|
||||
house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." },
|
||||
},
|
||||
},
|
||||
ShardMarketListing: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40012ABC' },
|
||||
itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' },
|
||||
hue: { type: 'integer', example: 0 },
|
||||
amount: { type: 'integer', example: 1 },
|
||||
price: { type: 'integer', example: 25000 },
|
||||
name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." },
|
||||
cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." },
|
||||
displayName: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'quarter staff',
|
||||
description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.',
|
||||
},
|
||||
child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' },
|
||||
vendor: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40001234' },
|
||||
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
||||
ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' },
|
||||
ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' },
|
||||
location: { $ref: '#/components/schemas/ShardMarketLocation' },
|
||||
updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardMarketPage: {
|
||||
type: 'object',
|
||||
description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.',
|
||||
properties: {
|
||||
listings: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } },
|
||||
total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' },
|
||||
limit: { type: 'integer', example: 50 },
|
||||
offset: { type: 'integer', example: 0 },
|
||||
vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' },
|
||||
staleAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.',
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardMarketVendor: {
|
||||
type: 'object',
|
||||
description: 'One player vendor and its listings.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40001234' },
|
||||
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
||||
ownerSerial: { type: 'string', nullable: true },
|
||||
ownerName: { type: 'string', nullable: true, example: 'Darrow' },
|
||||
location: { $ref: '#/components/schemas/ShardMarketLocation' },
|
||||
count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' },
|
||||
total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' },
|
||||
truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/ShardMarketListing' } },
|
||||
},
|
||||
},
|
||||
ShardMarketMeta: {
|
||||
type: 'object',
|
||||
description: 'Marketplace size, staleness and the filter options a client needs to build its UI.',
|
||||
properties: {
|
||||
vendors: { type: 'integer', example: 137 },
|
||||
items: { type: 'integer', example: 18422 },
|
||||
staleAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
freshAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." },
|
||||
regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] },
|
||||
},
|
||||
},
|
||||
ShardFeatures: {
|
||||
type: 'object',
|
||||
description:
|
||||
"The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.",
|
||||
properties: {
|
||||
level: {
|
||||
type: 'string',
|
||||
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
example: 'anonymous',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'],
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardFeatureVisibility: {
|
||||
type: 'object',
|
||||
description: 'Visibility settings for one shard feature.',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', example: true },
|
||||
audience: {
|
||||
type: 'string',
|
||||
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.',
|
||||
example: 'anonymous',
|
||||
},
|
||||
stream: {
|
||||
type: 'boolean',
|
||||
description: "Whether this feature's event kinds fan out over SSE at all.",
|
||||
example: true,
|
||||
},
|
||||
fieldRules: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description:
|
||||
'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.',
|
||||
example: { location: 'staff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardVisibilityConfig: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ladder: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
},
|
||||
lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] },
|
||||
defaults: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
||||
},
|
||||
features: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardVisibilityUpdate: {
|
||||
type: 'object',
|
||||
required: ['features'],
|
||||
properties: {
|
||||
features: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/ShardFeatureVisibility' },
|
||||
example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Spawn atlas (Protocol 3.0 Part C) ────────────────────────────────
|
||||
// Static shard content, parsed from the shard's own ServUO tree. Nothing
|
||||
// here comes from the sidecar, so it stays populated while the shard is
|
||||
// down. Facet names are whatever the shard's files declare — the examples
|
||||
// below are stock ServUO, not a fixed list.
|
||||
AtlasCreature: {
|
||||
type: 'object',
|
||||
description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'lizardman' },
|
||||
name: { type: 'string', example: 'Lizardman' },
|
||||
total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 },
|
||||
points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 },
|
||||
facets: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'integer' },
|
||||
description: "This creature's share per facet.",
|
||||
example: { Felucca: 96, Trammel: 88, Tokuno: 30 },
|
||||
},
|
||||
art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' },
|
||||
places: {
|
||||
type: 'array',
|
||||
description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Trammel' },
|
||||
label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' },
|
||||
spawners: { type: 'integer', example: 7 },
|
||||
maxAlive: { type: 'integer', example: 21 },
|
||||
},
|
||||
},
|
||||
},
|
||||
spawners: {
|
||||
type: 'array',
|
||||
description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.',
|
||||
items: { $ref: '#/components/schemas/AtlasSpawner' },
|
||||
},
|
||||
spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false },
|
||||
alsoHere: {
|
||||
type: 'array',
|
||||
description: 'Creatures sharing a spawner with this one.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'lizardman-warrior' },
|
||||
name: { type: 'string', example: 'Lizardman Warrior' },
|
||||
shared: { type: 'integer', example: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AtlasSpawner: {
|
||||
type: 'object',
|
||||
description: 'One ServUO spawner, with the place its coordinates resolved to.',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." },
|
||||
x: { type: 'integer', example: 5411 },
|
||||
y: { type: 'integer', example: 1234 },
|
||||
width: { type: 'integer' },
|
||||
height: { type: 'integer' },
|
||||
range: { type: 'integer', description: 'Spawn radius.' },
|
||||
maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 },
|
||||
minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 },
|
||||
maxDelay: { type: 'integer', example: 600 },
|
||||
todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' },
|
||||
todEnd: { type: 'integer' },
|
||||
todMode: { type: 'integer' },
|
||||
region: { type: 'string', nullable: true, example: 'Despise' },
|
||||
landmark: { type: 'string', nullable: true, example: 'Covetous' },
|
||||
label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' },
|
||||
},
|
||||
},
|
||||
AtlasCreaturePage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 },
|
||||
limit: { type: 'integer', example: 50 },
|
||||
offset: { type: 'integer', example: 0 },
|
||||
creatures: { type: 'array', items: { $ref: '#/components/schemas/AtlasCreature' } },
|
||||
},
|
||||
},
|
||||
AtlasRegion: {
|
||||
type: 'object',
|
||||
description: 'A named region, flattened out of the shard\'s nested Regions.xml.',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
name: { type: 'string', example: 'Despise' },
|
||||
type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' },
|
||||
priority: { type: 'integer', example: 50 },
|
||||
parent: { type: 'string', nullable: true, example: 'Britain' },
|
||||
rects: {
|
||||
type: 'array',
|
||||
description: 'The rectangles that placed each spawn point.',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
AtlasLandmark: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Trammel' },
|
||||
name: { type: 'string', example: 'Level 1' },
|
||||
group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' },
|
||||
x: { type: 'integer', example: 5411 },
|
||||
y: { type: 'integer', example: 1234 },
|
||||
z: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
AtlasChampion: {
|
||||
type: 'object',
|
||||
description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'felucca-deceit' },
|
||||
name: { type: 'string', example: 'Deceit' },
|
||||
group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' },
|
||||
type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' },
|
||||
randomType: { type: 'boolean', example: false },
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
x: { type: 'integer' },
|
||||
y: { type: 'integer' },
|
||||
z: { type: 'integer' },
|
||||
radius: { type: 'integer', example: 60 },
|
||||
label: { type: 'string', nullable: true, example: 'Deceit' },
|
||||
},
|
||||
},
|
||||
AtlasMeta: {
|
||||
type: 'object',
|
||||
description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.',
|
||||
properties: {
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
generatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
counts: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 },
|
||||
},
|
||||
facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] },
|
||||
},
|
||||
},
|
||||
AtlasStatus: {
|
||||
type: 'object',
|
||||
description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
|
||||
properties: {
|
||||
configured: { type: 'boolean', example: true },
|
||||
path: { type: 'string', example: '/srv/servuo' },
|
||||
treeReadable: { type: 'boolean', example: true },
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false },
|
||||
facets: { type: 'array', items: { type: 'string' } },
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
pending: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
AtlasRefreshResult: {
|
||||
type: 'object',
|
||||
description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'],
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
path: { type: 'string', nullable: true },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
addedFacets: { type: 'array', items: { type: 'string' } },
|
||||
removedFacets: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
ClilocStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids.',
|
||||
properties: {
|
||||
configured: { type: 'boolean', example: true },
|
||||
path: { type: 'string', example: '/srv/uo-client' },
|
||||
file: { type: 'string', nullable: true, description: 'The file actually resolved, when the path is a directory.', example: '/srv/uo-client/clilocs.tsv' },
|
||||
fileReadable: { type: 'boolean', example: true },
|
||||
problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null },
|
||||
code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] },
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false },
|
||||
count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 },
|
||||
sources: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Every source found now, root-relative, base first then overlays in merge order.',
|
||||
example: ['clilocs.plain', 'custom/uomysticmoon.tsv'],
|
||||
},
|
||||
loadedSources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'What each source contributed at the last import.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', example: 'custom/uomysticmoon.tsv' },
|
||||
kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' },
|
||||
entries: { type: 'integer', example: 37 },
|
||||
added: { type: 'integer', description: 'Ids this source introduced.', example: 25 },
|
||||
overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingSources: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Sources loaded previously and now absent. An import refuses these without `approve`.',
|
||||
example: [],
|
||||
},
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
sourceBytes: { type: 'integer', nullable: true, example: 4973525 },
|
||||
},
|
||||
},
|
||||
ClilocRefreshResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
|
||||
description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.',
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause. `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.',
|
||||
enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER'],
|
||||
},
|
||||
path: { type: 'string', nullable: true },
|
||||
file: { type: 'string', nullable: true },
|
||||
count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 },
|
||||
parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 },
|
||||
blank: { type: 'integer', nullable: true, example: 55994 },
|
||||
sources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
kind: { type: 'string', enum: ['base', 'custom'] },
|
||||
entries: { type: 'integer' },
|
||||
added: { type: 'integer' },
|
||||
overrode: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingSources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'string' },
|
||||
description: 'On `needsReview`: the sources that vanished. Nothing was applied.',
|
||||
},
|
||||
acceptedMissing: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'string' },
|
||||
description: 'On `imported` with `approve`: the vanished sources the admin accepted.',
|
||||
},
|
||||
},
|
||||
},
|
||||
ShardLinkRequest: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
|
||||
},
|
||||
},
|
||||
ShardLinkResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
linked: { type: 'boolean', example: true },
|
||||
account: { type: 'string', example: 'whitlocktech' },
|
||||
},
|
||||
},
|
||||
ShardLink: {
|
||||
type: 'object',
|
||||
description: 'A linked in-game account (GET /player/shard/accounts).',
|
||||
properties: {
|
||||
account: { type: 'string', example: 'whitlocktech' },
|
||||
userId: { type: 'integer', example: 42 },
|
||||
charName: { type: 'string', nullable: true, example: 'Darrow' },
|
||||
linkedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
TownCrierRequest: {
|
||||
type: 'object',
|
||||
required: ['id', 'lines'],
|
||||
properties: {
|
||||
id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
|
||||
lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
|
||||
durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1549,8 +980,37 @@ function normalizePaths(spec) {
|
||||
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||
process.env.DB_PORT = process.env.DB_PORT || '59999'
|
||||
|
||||
// ── An annotation swagger-autogen cannot parse is DROPPED, not failed ──────
|
||||
//
|
||||
// It `console.error`s "Syntax error" or "out of structure", skips that one
|
||||
// annotation, and prints `Success` in green. Nothing was listening, so the tree
|
||||
// had been carrying a broken one — `POST /api/v1/admin/invites` documented with an
|
||||
// EMPTY request body — for as long as it had existed. The same class turned up
|
||||
// four more times in module-uo, whose annotations came from here.
|
||||
//
|
||||
// Two ways one breaks, both of them invisible in review: an object literal a
|
||||
// brace short, and a `"` or a backtick inside a single-quoted description
|
||||
// (swagger-autogen re-quotes both to `'` before evaluating, which ends the string
|
||||
// early). Capturing the diagnostics is the only way to be told.
|
||||
const swaggerComplaints = []
|
||||
const realConsoleError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) swaggerComplaints.push(line.trim())
|
||||
else realConsoleError(...args)
|
||||
}
|
||||
|
||||
/* eslint-disable global-require */
|
||||
swaggerAutogen(outputFile, routes, doc)
|
||||
.then(() => {
|
||||
console.error = realConsoleError
|
||||
if (swaggerComplaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${swaggerComplaints.length} annotation(s) could not be parsed and were DROPPED ` +
|
||||
`— the spec would be missing what they described:\n ${swaggerComplaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
.then(() => require('./slotSpecs').mergeSlotSpecs(outputFile))
|
||||
.then(() => {
|
||||
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
|
||||
@@ -1561,6 +1021,7 @@ swaggerAutogen(outputFile, routes, doc)
|
||||
return require('../src/utils/db').close()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error = realConsoleError
|
||||
process.stderr.write(`${err.stack || err.message}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user