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

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