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

@@ -99,13 +99,15 @@ CLIENT_ORIGIN=http://localhost:5173
BOT_INTERNAL_URL=http://localhost:4100
BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# News announcement pipeline (published news post -> in-game town crier + Discord
# #news). The dispatcher is an in-process poller; these tune it. Links in the
# News announcement pipeline (published news post -> every registered delivery
# leg). The dispatcher is an in-process poller; this tunes it. Links in the
# announcements use APP_BASE_URL (set above), so set that in production too.
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400)
#
# Which legs exist depends on what has registered one: Discord (#news) is core's,
# and an installed module may add its own. A module's leg brings its own settings
# with it -- module-uo's in-game town crier reads TOWNCRIER_DURATION_SEC, which is
# documented in that module rather than here, because core has no town crier.
ANNOUNCE_POLL_MS=15000
TOWNCRIER_DURATION_SEC=3600
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
@@ -122,7 +124,7 @@ TOWNCRIER_DURATION_SEC=3600
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard.
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for this instance.
# NTFY_BASE_URL=https://ntfy.example.com
# NTFY_PUBLIC_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com

View File

@@ -111,15 +111,22 @@ app.use(
)
// ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/).
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. Core's own
// routes are generated from their annotations by `npm run swagger`
// (server/swagger/) and committed; an installed module's routes cannot be —
// swagger-autogen is static analysis and a module arrives on the volume after the
// image was built — so each module ships its own fragment and they are merged
// HERE, per request, over core's committed spec (docs/website/MODULE_API.md §6.1a).
// Loaded lazily and guarded so a missing spec never crashes the server.
try {
// eslint-disable-next-line global-require
/* eslint-disable global-require */
const swaggerSpec = require('../swagger/swagger-output.json')
const { docsSpec } = require('../swagger/docsSpec')
/* eslint-enable global-require */
app.get('/api/docs.json', (req, res) => {
// #swagger.ignore = true
res.json(swaggerSpec)
res.json(docsSpec(swaggerSpec))
})
// swagger-ui-express injects an inline bootstrap script and inline styles, which
// the global 'self'-only script-src would block — relax CSP for this route only.
@@ -132,10 +139,18 @@ try {
'upgrade-insecure-requests': null,
},
})
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
// `setup()` is called PER REQUEST rather than once here, because the document it
// renders is not fixed at boot: a module reaching `started` (or failing to) adds
// or removes paths, and a UI bound to the spec as it looked while app.js was
// still being required would show core's routes for the life of the process
// while /api/docs.json showed the merged set. `docsSpec` is cached on the
// loader's state version, so the repeated call costs a comparison.
const swaggerOpts = {
customSiteTitle: `${brand.name} API docs`,
swaggerOptions: { persistAuthorization: true },
}))
}
app.use('/api/docs', swaggerCsp, swaggerUi.serve, (req, res, next) =>
swaggerUi.setup(docsSpec(swaggerSpec), swaggerOpts)(req, res, next))
} catch (err) {
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
message: err.message,

View File

@@ -74,6 +74,13 @@ const MANIFEST_KEYS = new Set([
const modules = new Map()
let loaded = false
// Bumped by every state CHANGE. One consumer today: the merged OpenAPI document
// at /api/docs.json, which is built from the fragments of `started` modules and
// so has to be rebuilt when that set moves (§6.1a). A counter rather than an
// event, because the question a cache asks is "is what I have still current",
// and a number answers it without anyone having to remember to subscribe.
let stateVersion = 0
// ── ctx ────────────────────────────────────────────────────────────────────
// Everything a module may reach in core, and nothing else (§2.3). Required
@@ -709,6 +716,7 @@ function setState(id, state, { stage = null, reason = null } = {}) {
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
const record = modules.get(id)
if (!record) return
if (record.state !== state) stateVersion += 1
record.state = state
record.stage = state === 'startup_failed' ? stage : null
record.reason = state === 'startup_failed' ? reason : null
@@ -845,6 +853,39 @@ function clientEntryUrls() {
.map((r) => r.client.entryUrl)
}
/**
* Every started module's OpenAPI fragment, in scan order.
*
* `started` only, matching clientEntryUrls() rather than clientChunks(): the
* merged document is built when it is asked for, at which point the state is
* known, and documenting a module that is 503ing every one of those paths would
* send a client somewhere it cannot go.
*
* The filename is fixed by §2.8 — `swagger-fragment.json` in the bundle root —
* rather than declared in `module.json`, so a module cannot point core at
* something else. A module that ships none is simply absent: registering routes
* without documenting them is checked in the module's OWN CI (§2.8), where the
* routes are known; core has no way to tell the difference here between a module
* with no routes and one that forgot.
*
* @returns {{id: string, file: string}[]}
*/
function specFragments() {
assertLoaded('specFragments')
return [...modules.values()]
.filter((r) => r.state === 'started')
.map((r) => ({ id: r.id, file: path.join(r.dir, 'swagger-fragment.json') }))
.filter((f) => fs.existsSync(f.file))
}
/**
* How many times a module's state has CHANGED in this process.
*
* A cache key, and nothing more: hold the value you built with, compare, rebuild
* when it differs. It says nothing about which module moved or where to.
*/
const version = () => stateVersion
/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR
@@ -857,6 +898,8 @@ module.exports = {
shutdownHooks,
clientChunks,
clientEntryUrls,
specFragments,
version,
isLoaded,
dir,
}

View File

@@ -19,7 +19,7 @@ invitesRouter.post(
// #swagger.tags = ['Admin · Invites']
// #swagger.summary = 'Create and email an account invite at a chosen access level'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } } */
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
adminOnly,

View File

@@ -33,7 +33,7 @@ inviteRouter.post(
// #swagger.tags = ['Auth']
// #swagger.summary = 'Accept an email invite (creates the account at the invited role)'
// #swagger.description = 'Creates the website user at the invites pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } } */
/* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

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

@@ -0,0 +1,100 @@
// ── The OpenAPI document core actually serves ──────────────────────────────
//
// `swagger-output.json` is core's own routes and only core's own routes: it is
// generated by `npm run swagger` on a developer's machine and committed, so it
// must come out the same regardless of which modules that developer happened to
// have checked out. A module's routes cannot be in it, and not merely because
// nobody put them there — a module arrives on a volume long after the image was
// built, and core never has its sources to analyse.
//
// So the document served at `/api/docs.json` is assembled at REQUEST time: core's
// committed spec, plus the `swagger-fragment.json` of every started module
// (docs/website/MODULE_API.md §2.8 and §6.1a). This file is that assembly.
//
// **Core always wins a key collision.** `mergeFragment` enforces it and reports
// what it dropped. A module cannot redefine a core path, tag or schema by shipping
// one with the same name — which is why §6.1a tells modules to namespace the
// schemas they define (`UoShardStatus`) while referencing core's shared ones
// (`Error`) by core's name: the first would collide and lose, the second resolves
// here, in the merged document, which is the only place both exist.
//
// **Cached, keyed on the loader's state version.** Building the document reads a
// file per module and deep-copies a 5,000-line spec; `/api/docs` is a page an
// operator opens occasionally and a crawler may hit repeatedly. The cache is
// invalidated by any module state CHANGE — which is what "started modules only"
// depends on, and the only input here that can move without a restart.
const fs = require('fs')
const modules = require('../src/modules/loader')
const createLogger = require('../src/utils/logger')
const { mergeFragment } = require('./mergeSpec')
const log = createLogger('swagger')
let cached = null
let cachedVersion = -1
/**
* Core's spec with every started module's fragment merged over it.
*
* Never throws: `/api/docs.json` answering with core's routes alone is a worse
* document than the full one, but it is a document. A fragment that is missing,
* unreadable or not JSON costs that module its paths and nothing else — the same
* bargain §4.4 makes everywhere else, where one module's failure is never the
* site's.
*
* @param {object} coreSpec the committed swagger-output.json — never mutated
* @returns {object}
*/
function docsSpec(coreSpec) {
// Before app.js has called modules.load(), asking is a mis-ordered boot rather
// than a core with nothing installed (§7.6) — but this is a request handler, and
// 500ing the docs page over it would be the wrong trade. Core's own spec is the
// honest answer to "what is documented" at that point anyway.
if (!modules.isLoaded()) return coreSpec
const version = modules.version()
if (cached && cachedVersion === version) return cached
// A structural copy, because mergeFragment writes into what it is given and
// `coreSpec` is a require()d JSON module: mutating it would make the merge
// cumulative across rebuilds and permanent for the life of the process.
const spec = JSON.parse(JSON.stringify(coreSpec))
spec.paths = spec.paths || {}
spec.tags = spec.tags || []
spec.components = spec.components || {}
spec.components.schemas = spec.components.schemas || {}
for (const { id, file } of modules.specFragments()) {
let fragment
try {
fragment = JSON.parse(fs.readFileSync(file, 'utf8'))
} catch (err) {
log.warn('module OpenAPI fragment could not be read — its routes will be undocumented', {
module: id,
file,
error: err.message,
})
continue
}
const before = Object.keys(spec.paths).length
mergeFragment(spec, fragment, `module ${id}`)
log.debug('merged module OpenAPI fragment', {
module: id,
paths: Object.keys(spec.paths).length - before,
})
}
cached = spec
cachedVersion = version
return spec
}
/** Test seam: forget the cached document. */
function reset() {
cached = null
cachedVersion = -1
}
module.exports = { docsSpec, reset }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,205 @@
// ── /api/docs.json, with a module installed ────────────────────────────────
//
// The request-time half of docs/website/MODULE_API.md §6.1's settled decision.
// `swagger-output.json` is core's own routes and cannot be anything else: it is
// generated on a developer's machine and committed, so it has to come out the
// same regardless of what they had checked out, and a module arrives on the
// volume long after the image was built. The module's routes therefore reach the
// document only here, from the fragment it ships (§2.8, §6.1a).
//
// This boots the REAL app against a throwaway module directory, because the two
// things worth locking are properties of the served document rather than of the
// merge helper: that a started module's paths are IN it, and that core wins.
//
// The modules directory is written and MODULES_DIR set BEFORE app.js is required
// — the scan is synchronous and happens during that require.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, before, after } = require('node:test')
const assert = require('node:assert/strict')
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-docs-'))
const moduleDir = path.join(tmpRoot, 'atlas')
fs.mkdirSync(path.join(moduleDir, 'client', 'dist'), { recursive: true })
fs.writeFileSync(path.join(moduleDir, 'client', 'dist', 'entry.js'), 'export const hello = 1\n')
fs.writeFileSync(
path.join(moduleDir, 'module.json'),
JSON.stringify({
id: 'atlas',
name: 'Atlas',
version: '1.0.0',
coreApi: '^1.0.0',
client: { entry: 'client/dist/entry.js' },
}),
)
fs.writeFileSync(
path.join(moduleDir, 'swagger-fragment.json'),
JSON.stringify({
paths: {
'/api/v1/public/atlas/creatures': { get: { tags: ['Public · Atlas'], summary: 'List creatures' } },
// The collision case, and the one that matters: a module trying to
// redefine a path core already declares. Core wins and the module's
// version is dropped (§6.1a) — a module cannot rewrite core's docs.
'/api/v1/public/settings': { get: { summary: 'MODULE OVERRIDE' } },
},
tags: [{ name: 'Public · Atlas', description: 'from the module' }],
components: {
schemas: {
AtlasCreature: { type: 'object' },
// Same shape of collision, one section down.
Error: { type: 'string', description: 'MODULE OVERRIDE' },
},
},
}),
)
process.env.MODULES_DIR = tmpRoot
/* eslint-disable global-require */
const app = require('../src/app')
const loader = require('../src/modules/loader')
const db = require('../src/utils/db')
const coreSpec = require('../swagger/swagger-output.json')
const { docsSpec, reset } = require('../swagger/docsSpec')
/* eslint-enable global-require */
let server
let base
before(async () => {
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
base = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
await db.close()
fs.rmSync(tmpRoot, { recursive: true, force: true })
})
const fetchSpec = async () => {
const res = await fetch(`${base}/api/docs.json`)
assert.equal(res.status, 200)
return res.json()
}
test('a module that is not started contributes nothing', async () => {
// It is `registered` here: loaded cleanly, onBoot not yet dispatched. Its
// routes answer 503 in that state, so documenting them would send a client
// somewhere it cannot go — the same reason the client chunk's <script> tag is
// withheld until `started`.
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
})
test('a started module\'s paths, tags and schemas are in the served document', async () => {
loader.setState('atlas', 'started')
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'].get.summary, 'List creatures')
assert.ok(spec.tags.some((t) => t.name === 'Public · Atlas'))
assert.deepEqual(spec.components.schemas.AtlasCreature, { type: 'object' })
})
test('core wins every collision, in every section', async () => {
loader.setState('atlas', 'started')
const spec = await fetchSpec()
assert.notEqual(spec.paths['/api/v1/public/settings'].get.summary, 'MODULE OVERRIDE')
assert.notEqual(spec.components.schemas.Error.description, 'MODULE OVERRIDE')
assert.deepEqual(spec.components.schemas.Error, coreSpec.components.schemas.Error)
})
test('the committed spec is never mutated by the merge', async () => {
// `swagger-output.json` is a require()d JSON module, so one in-place merge
// would be permanent for the life of the process AND cumulative across
// rebuilds — a module's paths surviving its own uninstall.
loader.setState('atlas', 'started')
await fetchSpec()
assert.equal(coreSpec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.equal(coreSpec.components.schemas.AtlasCreature, undefined)
})
test('a state change rebuilds the document rather than serving the cached one', async () => {
loader.setState('atlas', 'started')
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
loader.setState('atlas', 'disabled')
assert.equal((await fetchSpec()).paths['/api/v1/public/atlas/creatures'], undefined)
loader.setState('atlas', 'started')
assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
})
test('an unreadable fragment costs that module its paths and nothing else', async () => {
// §4.4's bargain, applied here: one module's failure is never the site's. A
// docs page that 500s is strictly worse than one missing a module's routes.
loader.setState('atlas', 'started')
const file = path.join(moduleDir, 'swagger-fragment.json')
const good = fs.readFileSync(file, 'utf8')
fs.writeFileSync(file, 'not json {')
try {
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.ok(spec.paths['/api/v1/public/settings'], 'core\'s own paths must survive')
} finally {
fs.writeFileSync(file, good)
reset()
}
})
test('a module with no fragment at all is simply absent', async () => {
// Registering routes without documenting them is checked in the MODULE's CI,
// where the routes are known. Core cannot tell a module with no routes from
// one that forgot, so it does not guess.
loader.setState('atlas', 'started')
const file = path.join(moduleDir, 'swagger-fragment.json')
const good = fs.readFileSync(file, 'utf8')
fs.rmSync(file)
try {
reset()
const spec = await fetchSpec()
assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
assert.ok(Object.keys(spec.paths).length > 100)
} finally {
fs.writeFileSync(file, good)
reset()
}
})
test('before the loader has scanned, core\'s own spec is the answer', () => {
// §7.6 makes every other accessor THROW when asked before modules.load(), so a
// mis-ordered boot cannot be mistaken for an empty install. A request handler
// is the exception: 500ing the docs page over it would be the wrong trade, and
// core's routes are the honest answer to "what is documented" at that point.
reset()
const stub = { isLoaded: () => false }
const original = Object.getOwnPropertyDescriptor(loader, 'isLoaded')
Object.defineProperty(loader, 'isLoaded', { value: stub.isLoaded, configurable: true })
try {
assert.equal(docsSpec(coreSpec), coreSpec)
} finally {
Object.defineProperty(loader, 'isLoaded', original)
reset()
}
})
test('the interactive UI is rebuilt per request, not bound to boot\'s document', async () => {
// Bound once at require time, /api/docs would show core's routes for the life
// of the process while /api/docs.json showed the merged set.
loader.setState('atlas', 'started')
reset()
const withModule = await fetch(`${base}/api/docs/`)
assert.equal(withModule.status, 200)
assert.match(await withModule.text(), /swagger/i)
})