feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 31s

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:
2026-08-11 22:57:37 -05:00
parent 87230c879a
commit adff20be7b
11 changed files with 643 additions and 5227 deletions

View File

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