feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 19s
PR Checks / frozen-manifest (pull_request) Successful in 35s
PR Checks / client-build (pull_request) Successful in 8m49s

The three artifacts that make this module installable and checkable, closing
phase 3's extraction. Nothing about what the module serves changes: the same 72
URLs, the same behaviour.

**The OpenAPI fragment (MODULE_API.md §2.8, §6.1a) was never built, on either
side.** The 417 `#swagger` annotations came across in slice 1 and went nowhere,
and core's /api/docs.json merged nothing — so every route this module serves was
in no spec at all, which is core's standing rule ("never ship a route that isn't
in the spec") being broken by the extraction rather than by a route.

`server/scripts/swaggerFragment.js` generates it. The prefixes are DERIVED: the
script runs the module's own `register()` against a recording api and asks
`require.cache` which file each router came from, so a mount prefix exists in one
place — `server/index.js` — and not in a table beside it. The 31 schemas moved
here from core's swagger.js, namespaced `Uo…` because core wins every key
collision in the merge; `Error` and `ValidationError` stay referenced by core's
names, since they resolve in the merged document.

**The frozen route manifest (§5.3)** is derived too, and by subtraction: CI
clones core at the ref pinned in ci/core-ref.json, generates its manifest without
this module and then with it, and the difference is what this module serves. That
buys the half of §5.3 that matters most for free — a module that shadowed or
displaced one of core's routes shows up as a REMOVAL, not merely as an addition
elsewhere. The same job checks the fragment against ground truth: every route
must have an operation and every operation must be a route.

**The release workflow** publishes `module-uo-<version>.tar.gz` plus a manifest
carrying its sha256. The version is declared in module.json rather than computed
from commit subjects, and the workflow never writes to a branch — it tags and
publishes — so `main` needs no push exception. The bundle is assembled from an
include list, because an exclude list ships whatever it forgot.

Four annotation defects, inherited from core and never visible until something
generated a spec from these files: two `requestBody` literals a brace short (the
route documented with an empty body), and two descriptions whose inner quoting
swagger-autogen cannot survive — it re-quotes `"` and a backtick to `'` before
evaluating, so either inside a single-quoted description ends the string early
and the annotation is dropped. It reports each one and then prints Success in
green, so the generator now captures its diagnostics and makes them fatal.

Also fixed while writing it: passing one shared `doc` to swagger-autogen six
times. It renders components.schemas from an EXAMPLE object and writes the result
back into what it was handed, so each pass re-wrapped the last and the fragment
came out at 484 MB.

- 409 server tests (+21), 40 client tests unchanged
- swagger-fragment.json: 69 paths covering all 72 routes
- routes.manifest.json: 72 routes; core's own surface unchanged, 0 removals
- verified end to end by assembling the bundle exactly as CI will, unpacking it
  into a real core and regenerating the manifest

Refs: docs/website/MODULE_SYSTEM.md §2.7.1, MODULE_API.md §2.8, §5.3, §6.1a

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:40:21 -05:00
parent e468bbd3b9
commit 5cdcf0fbb6
20 changed files with 10533 additions and 86 deletions

View File

@@ -43,7 +43,7 @@ atlasRouter.get(
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreaturePage" } } } } */
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
@@ -63,7 +63,7 @@ atlasRouter.get(
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreature" } } } } */
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('slug').isString().isLength({ min: 1, max: 120 }),
facetParam,
@@ -80,7 +80,7 @@ atlasRouter.get(
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasRegion" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
@@ -95,7 +95,7 @@ atlasRouter.get(
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasLandmark" } } } } } */
facetParam,
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
validate,
@@ -109,7 +109,7 @@ atlasRouter.get(
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasChampion" } } } } } */
facetParam,
validate,
siteMode,
@@ -121,7 +121,7 @@ atlasRouter.get(
// #swagger.tags = ['Public · Atlas']
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasMeta" } } } } */
siteMode,
atlas.getMeta,
)

View File

@@ -37,7 +37,7 @@ shardRouter.get(
requireFeature('status'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard connection state, online count and latest economy'
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardStatus" } } } } */
shard.getStatus,
)
shardRouter.get(
@@ -48,7 +48,7 @@ shardRouter.get(
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
query('limit').optional().isInt({ min: 1, max: 1000 }),
validate,
@@ -60,7 +60,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEconomyPoint" } } } } } */
query('limit').optional().isInt({ min: 1, max: 1000 }),
validate,
shard.getEconomy,
@@ -70,7 +70,7 @@ shardRouter.get(
requireFeature('presence'),
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardOnlinePlayer" } } } } } */
shard.getOnline,
)
shardRouter.get(
@@ -79,7 +79,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Houses currently in danger (IDOC)'
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getIdoc,
)
shardRouter.get(
@@ -137,7 +137,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getHouses,
)
shardRouter.get(
@@ -155,7 +155,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } } */
shard.getPointsBoards,
)
shardRouter.get(
@@ -165,7 +165,7 @@ shardRouter.get(
// #swagger.summary = 'One points system\'s leaderboard'
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } */
/* #swagger.responses[400] = { description: 'Malformed system name' } */
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
shard.getPointsBoard,
@@ -185,13 +185,13 @@ shardRouter.get(
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' }
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match — the more-like-this filter.' }
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
// #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' }
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' }
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. recent orders by when the shop was last seen.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketPage" } } } } */
/* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -213,7 +213,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Marketplace size, staleness and filter options'
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketMeta" } } } } */
shard.getMarketMeta,
)
shardRouter.get(
@@ -226,7 +226,7 @@ shardRouter.get(
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketVendor" } } } } */
/* #swagger.responses[400] = { description: 'Malformed vendor serial' } */
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
param('serial').isString().isLength({ max: 20 }),
@@ -240,7 +240,7 @@ shardRouter.get(
// #swagger.tags = ['Public · Shard']
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardFeatures" } } } } */
shard.getFeatures,
)
shardRouter.get(