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

158
server/package-lock.json generated
View File

@@ -13,7 +13,8 @@
},
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
"express-validator": "^7.2.0",
"swagger-autogen": "^2.23.7"
},
"engines": {
"node": ">=20"
@@ -33,6 +34,19 @@
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -40,6 +54,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -65,6 +86,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -106,6 +138,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -156,6 +195,16 @@
"ms": "2.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -359,6 +408,13 @@
"node": ">= 0.6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -408,6 +464,28 @@
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -481,6 +559,18 @@
"node": ">=0.10.0"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -498,6 +588,19 @@
"node": ">= 0.10"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -581,6 +684,19 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -624,6 +740,16 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -634,6 +760,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -867,6 +1003,19 @@
"node": ">= 0.8"
}
},
"node_modules/swagger-autogen": {
"version": "2.23.7",
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz",
"integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^7.4.1",
"deepmerge": "^4.2.2",
"glob": "^7.1.7",
"json5": "^2.2.3"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -931,6 +1080,13 @@
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",

View File

@@ -7,7 +7,9 @@
"main": "index.js",
"scripts": {
"test": "node --test --require ./test/_setup.js",
"check:imports": "node scripts/checkImports.js"
"check:imports": "node scripts/checkImports.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
},
"engines": {
"node": ">=20"
@@ -15,10 +17,11 @@
"//dependencies": "The ONE runtime dependency, and it ships inside the release tarball: CI runs npm ci --omit=dev and packs server/node_modules, because an operator never builds (MODULE_SYSTEM.md 1.14). Node resolves it by walking up from modules/uo/server/. Everything else the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger, the middleware and the rate-limit factory are all core-owned and handed over.",
"devDependencies": {
"express": "^4.19.2",
"express-validator": "^7.2.0"
"express-validator": "^7.2.0",
"swagger-autogen": "^2.23.7"
},
"dependencies": {
"ws": "^8.21.0"
},
"//devDependencies": "Test-only. test/_fakes.js builds a REAL express router - a fake Router would test the fake."
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express router - a fake Router would test the fake. swagger-autogen is the same generator core uses, pinned to the same major so the fragment and the spec it merges into come out of one tool (MODULE_API.md 2.8)."
}

View File

@@ -47,8 +47,8 @@ shardRouter.post(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
@@ -59,7 +59,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'List the callers linked game accounts (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
selfShard.listAccounts,
)
shardRouter.get(
@@ -103,7 +103,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
selfShard.getSales,
)
shardRouter.post(
@@ -112,7 +112,7 @@ shardRouter.post(
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shards mode; the password is never stored or logged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -223,7 +223,7 @@ shardRouter.get(
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
modAccess,
shardOps.listAudit,
)
@@ -233,7 +233,7 @@ shardRouter.get(
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #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" } } } } } */
modAccess,
shardOps.listHouses,
)
@@ -253,7 +253,7 @@ shardRouter.get(
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardAtlas.getStatus,
@@ -265,7 +265,7 @@ shardRouter.post(
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
adminOnly,
body('force').optional().isBoolean(),
validate,
@@ -277,7 +277,7 @@ shardRouter.post(
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
adminOnly,
shardAtlas.approve,
)
@@ -287,7 +287,7 @@ shardRouter.post(
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardAtlas.reject,
@@ -299,7 +299,7 @@ shardRouter.put(
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
adminOnly,
body('path').isString().isLength({ max: 512 }),
validate,
@@ -322,7 +322,7 @@ shardRouter.get(
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardClilocs.getStatus,
@@ -334,7 +334,7 @@ shardRouter.post(
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocRefreshResult" } } } } */
adminOnly,
body('force').optional().isBoolean(),
body('approve').optional().isBoolean(),
@@ -348,7 +348,7 @@ shardRouter.put(
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
adminOnly,
body('path').isString().isLength({ max: 512 }),
validate,
@@ -364,7 +364,7 @@ shardRouter.get(
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
shardVisibility.getVisibility,
@@ -375,8 +375,8 @@ shardRouter.put(
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('features').isObject(),

View File

@@ -100,7 +100,7 @@ uoLinkRouter.post(
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoTownCrierRequest" } } } } */
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */

View File

@@ -39,7 +39,7 @@ shardRouter.get(
// #swagger.summary = 'A users linked game accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
@@ -51,7 +51,7 @@ shardRouter.get(
// #swagger.summary = 'Recent vendor sales on a users accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,

View File

@@ -30,8 +30,8 @@ shardRouter.post(
// #swagger.summary = 'Link an in-game account with a one-time code'
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
@@ -44,7 +44,7 @@ shardRouter.post(
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -62,7 +62,7 @@ shardRouter.get(
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'List the callers linked game accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
shard.listAccounts,
)
shardRouter.get(
@@ -109,7 +109,7 @@ shardRouter.get(
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
shard.getSales,
)
shardRouter.get(
@@ -118,7 +118,7 @@ shardRouter.get(
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
shard.getHouses,
)

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(

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
//
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
// live Express stack and committing the result; a PR that moves a URL has to
// commit the new manifest, which puts the change in front of a reviewer. After
// phase 3 the seventy URLs this module serves are no longer in that file. They
// are here, frozen the same way and by the same generator.
//
// **The module's routes are DERIVED, never listed.** This script is handed two
// manifests generated from the SAME core at the pinned ref — one without this
// module on the volume, one with — and the difference is what this module serves.
// Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly
// one place, `server/index.js`'s `registerRoutes` call, which is where an operator's
// core reads it from too.
//
// Taking the difference rather than filtering by prefix buys the other half of
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
// A module that shadowed a core route, or whose mount displaced one, shows up
// here as a removal or a change, not merely as an addition somewhere else. That
// is the promise §1.2 makes to the shipped Android app and the Discord bot.
//
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
// is generated from the module's own registrations against §2.4's stated tier
// bases — the one place a constant could be wrong. Here there is ground truth: a
// real core with this module loaded, reporting the URLs it actually serves. Every
// route must have a documented operation and every documented operation must be a
// route. That is the per-module form of core's standing rule, never ship a route
// that isn't in the spec — and it is what stops a wrong constant in the generator
// from producing a fragment that is internally consistent and describes nothing
// core will ever serve.
//
// Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml):
// node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json
// node scripts/frozenManifest.js --before … --after … --check
const fs = require('fs')
const path = require('path')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
const COMMENT =
'Generated inventory of the URLs module-uo serves - the module half of the freeze ' +
'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' +
'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' +
'Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.'
const key = (r) => `${r.method} ${r.path}`
/**
* The module's routes, plus proof that core's own surface did not move.
*
* @param {object} before routes.manifest.json from core alone
* @param {object} after routes.manifest.json from the same core with this module
* @returns {{ added: object[], removed: string[] }}
*/
function diffManifests(before, after) {
const added = []
const removed = []
for (const tier of ['public', 'internal']) {
const was = new Set((before[tier] || []).map(key))
for (const route of after[tier] || []) {
if (!was.has(key(route))) added.push({ ...route, tier })
was.delete(key(route))
}
for (const gone of was) removed.push(`${tier} ${gone}`)
}
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
return { added, removed }
}
/**
* Which of the module's routes the fragment fails to document, and vice versa.
*
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
* because that is what core merges, so the manifest's paths are converted here
* rather than the other way round.
*/
function coverage(added, fragment) {
const documented = new Set()
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
}
const undocumented = []
for (const route of added) {
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
if (documented.has(oas)) documented.delete(oas)
else undocumented.push(oas)
}
// Whatever is left is documented and not served: a route that moved or was
// deleted while its annotation stayed behind. Core's spec has no equivalent
// check and grew four orphan tags and thirty-three orphan schemas because of it.
return { undocumented, unserved: [...documented].sort() }
}
function serialize(routes) {
return `${JSON.stringify(
{
$comment: COMMENT,
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
},
null,
2,
)}\n`
}
function main() {
const arg = (name) => {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
const beforePath = arg('--before')
const afterPath = arg('--after')
if (!beforePath || !afterPath) {
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
process.exit(2)
}
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
const { added, removed } = diffManifests(before, after)
let failed = false
if (removed.length > 0) {
process.stderr.write(
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
)
failed = true
}
if (added.length === 0) {
process.stderr.write(
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
)
process.exit(1)
}
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
const { undocumented, unserved } = coverage(added, fragment)
if (undocumented.length > 0) {
process.stderr.write(
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
)
failed = true
}
if (unserved.length > 0) {
process.stderr.write(
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
'A documented URL nobody serves is a client following the docs into a 404.\n',
)
failed = true
}
if (failed) process.exit(1)
const contents = serialize(added)
if (process.argv.includes('--check')) {
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
if (current !== contents) {
process.stderr.write(
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
'commit the result so the move is reviewed rather than merged as mechanical.\n',
)
process.exit(1)
}
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
return
}
fs.writeFileSync(MANIFEST, contents)
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
}
if (require.main === module) main()
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
// ── §2.8 — the OpenAPI fragment ────────────────────────────────────────────
//
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
// tags and schemas describing every route this module registers. Core merges the
// fragments of *started* modules over its own committed spec at request time and
// serves the result at `/api/docs.json` (docs/website/MODULE_API.md §6.1a).
//
// **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC
// analysis — swagger-autogen parses `src/app.js` as text and follows the literal
// `app.use(...)` chain. A module arrives on a volume after core was built, is
// required by a filesystem loop, and mounts through `api.registerRoutes()`. There
// is no literal mount for a parser to follow and core does not have our sources
// anyway, so nothing core can run will ever describe these routes. The failure
// mode is the dangerous one: swagger-autogen reports success and emits a spec
// with the routes simply absent (§6.1, and core hit it twice — the spike's atlas
// paths and PR 4's 407 deleted lines).
//
// ── Where the prefixes come from ───────────────────────────────────────────
//
// swagger-autogen is pointed at one router file at a time, so its paths come out
// relative to that router (`/status`, not `/api/v1/public/shard/status`) — nothing
// in the file says where it hangs. §6.1a requires fully-qualified paths, because
// core merges the fragment verbatim and never re-derives a prefix.
//
// So this script **runs the module's own `register()`** against a recording `api`
// and reads the mounts back out of it. The prefix of every router is therefore the
// prefix that router is actually registered under — the same call an operator's
// core will make, not a table beside it that drifts the first time a mount moves.
// Which router a recorded object came from is answered by `require.cache`: the
// file whose `module.exports` IS this router.
//
// The two things that cannot be derived here are the tier base paths and the
// extension slot's mount, because they are core's, not ours. They are §2.4's
// normative table, quoted below — and they are not taken on trust: the frozen
// route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real
// core with this module loaded, and fails if a fragment path is not among them.
// That check is where a wrong constant here dies.
const fs = require('fs')
const os = require('os')
const path = require('path')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const { fakeCtx, fakeApi } = require('../test/_fakes')
const doc = require('../swagger/doc')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
// router in core, behind its gate; the tier's own base path is core's and fixed
// by §1.2's frozen URL surface.
const TIER_BASE = {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
}
// MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core
// may declare one — so a module filling it has to be told where it landed.
const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
/**
* Run `register()` with a recording api and return `[{ file, prefix }]`.
*
* The ctx is the test fakes' — the same one the suite proves the module runs
* against — because registration must not touch a database (§2.2 rule 1) and this
* script is exactly the kind of no-database caller that rule exists for.
*/
function mountedRouters() {
const register = require('../index')
const api = fakeApi()
register(fakeCtx(), api)
const fileOf = (router) => {
for (const mod of Object.values(require.cache)) {
if (mod && mod.exports === router) return mod.filename
}
return null
}
const mounts = []
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
const base = TIER_BASE[tier]
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
for (const [prefix, router] of Object.entries(byPrefix)) {
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
}
}
for (const { slot, router } of api.record.extensions) {
const mount = SLOT_MOUNT[slot]
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
}
return mounts.map(({ router, prefix, what }) => {
const file = fileOf(router)
if (!file) {
// A router built inline in index.js rather than required from its own file.
// swagger-autogen needs a file to read, so there is nothing to generate from.
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
}
return { file, prefix, what }
})
}
/**
* Run swagger-autogen over one router file. Paths come out router-relative.
*
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
* `console.error`s "Syntax error" or "out of structure", drops that one
* annotation, and prints `Success` in green. Four of the annotations that came
* across in slice 1 were broken that way and had been for as long as they had
* existed in core: two `requestBody` literals a brace short, and two descriptions
* whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to
* `'` before evaluating, so either inside a single-quoted description ends the
* string early). The visible result was a documented route missing its body, or a
* typed query parameter demoted to an untyped one.
*
* So its diagnostics are captured and made fatal. This is the same class as every
* other failure in this seam — a generator that reports success while silently
* dropping what it was asked to describe (§6.1) — and the only difference is that
* here the tool does say something. Nothing was listening.
*/
async function fragmentFor(file) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-swagger-'))
const out = path.join(dir, 'fragment.json')
const complaints = []
const realError = console.error
console.error = (...args) => {
const line = args.map(String).join(' ')
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
else realError(...args)
}
try {
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
// renders `components.schemas` from an EXAMPLE object rather than treating it
// as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object',
// properties: { type: { type: 'string', example: 'object' } } }`, a
// meta-description of itself. That shape is uniform across core's committed
// spec and is the house shape, so it is matched rather than fought. What is
// NOT survivable is that it writes the result back into the object it was
// handed: reusing one `doc` across six routers re-wraps the previous pass's
// output five more times, and the fragment came out at 484 MB.
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
...JSON.parse(JSON.stringify(doc)),
info: { title: 'module-uo fragment', version: '0' },
})
} finally {
console.error = realError
}
if (complaints.length > 0) {
throw new Error(
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
)
}
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
fs.rmSync(dir, { recursive: true, force: true })
return fragment
}
/**
* Re-root a router-relative fragment under the prefix it is mounted at.
*
* Express path params (`:id`) become OpenAPI's (`{id}`), and the prefix's own
* params are moved to the FRONT of each operation's parameter list: swagger-autogen
* orders parameters by where they appeared in the path it saw, which was only the
* tail, so `/{id}/shard/link/{account}` would otherwise document (account, id).
*/
function prefixPaths(fragment, prefix) {
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
const paths = {}
for (const [p, item] of Object.entries(fragment.paths || {})) {
for (const operation of Object.values(item)) {
const params = operation && operation.parameters
if (!Array.isArray(params)) continue
const rank = (q) => {
const i = outer.indexOf(q && q.name)
return i === -1 ? outer.length : i
}
operation.parameters = params
.map((q, i) => ({ q, i }))
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
.map(({ q }) => q)
}
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`,
// a URL no client calls. Core's swagger.js normalizes the same way.
paths[`${oas}${p}`.replace(/\/$/, '')] = item
}
return paths
}
/**
* Build the whole fragment: every mounted router, re-rooted and merged.
*
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a allows
* a fragment to carry. `info`, `servers` and the security schemes are the merged
* document's, which is to say core's.
*/
async function build() {
const spec = { paths: {}, tags: [], components: { schemas: {} } }
let shared = false
for (const { file, prefix, what } of mountedRouters()) {
const generated = await fragmentFor(file)
// The tags and schemas are the SAME on every pass — each was handed the same
// `doc` — so they are taken from whichever ran first rather than from `doc`
// itself. What lands in the fragment has to be what swagger-autogen produced,
// not what it was given: those two differ (see fragmentFor), and core merges
// this file verbatim into a spec whose own schemas went through the same mill.
if (!shared) {
spec.tags = generated.tags || []
spec.components.schemas = (generated.components || {}).schemas || {}
shared = true
}
const paths = prefixPaths(generated, prefix)
const count = Object.keys(paths).length
if (count === 0) {
// An empty fragment is precisely what the silent drop looks like, so it is
// a hard failure rather than a router that happens to declare no routes.
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
}
for (const [p, item] of Object.entries(paths)) {
if (spec.paths[p]) {
throw new Error(`swagger: two of this module's routers both document ${p}`)
}
spec.paths[p] = item
}
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix}${what}\n`)
}
// Sorted, for the reason core sorts: swagger-autogen emits router-traversal
// order, so moving a route between files would rewrite most of this committed
// artifact even when the API is provably unchanged.
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
return spec
}
async function main() {
const check = process.argv.includes('--check')
const spec = await build()
const json = `${JSON.stringify(spec, null, 2)}\n`
if (!check) {
fs.writeFileSync(FRAGMENT, json)
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)}${Object.keys(spec.paths).length} paths\n`)
return
}
if (!fs.existsSync(FRAGMENT)) {
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
process.exit(1)
}
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
process.stderr.write(
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
'so a stale one documents a URL surface this module does not serve.\n',
)
process.exit(1)
}
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
}
if (require.main === module) {
main().catch((err) => {
process.stderr.write(`${err.stack}\n`)
process.exit(1)
})
}
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, SLOT_MOUNT, FRAGMENT }

621
server/swagger/doc.js Normal file
View File

@@ -0,0 +1,621 @@
// ── module-uo's OpenAPI fragment: the shared half ──────────────────────────
//
// The tags and component schemas every `#swagger.*` annotation under
// `server/router/**` refers to. `scripts/swaggerFragment.js` feeds this to
// swagger-autogen; the per-endpoint detail lives beside each route, exactly as
// it does in core.
//
// These 31 schemas were core's until phase 3 — they sat in
// `website/server/swagger/swagger.js` describing routes core no longer serves,
// which is what an extraction leaves behind if nobody looks (the inert-leaf
// class slice 4 found in `api/client.js`). They moved with the routes.
//
// **Two rules about names, and both are the merged document's, not this file's**
// (docs/website/MODULE_API.md §6.1a):
//
// • **What this module DEFINES is namespaced `Uo…`.** Core merges started
// modules' fragments into one `/api/docs.json`, and core wins every key
// collision — so an un-namespaced `ShardStatus` from a second game's module
// would silently lose to, or clobber, this one. The prefix is what makes two
// modules able to describe the same idea.
// • **What core defines is referenced by CORE's name.** The annotations point
// at `#/components/schemas/Error` and `ValidationError` and this file does
// not redefine them: they resolve in the merged spec, where core's
// definitions are. Shipping our own copy would be a collision core drops,
// which is the correct outcome arrived at the expensive way.
//
// Tag NAMES are core's originals (`Public · Shard`, not `Uo · Shard`). A tag is
// how the docs UI groups operations, and core stopped declaring these four in
// the same slice this file started — nothing collides, and renaming them would
// churn every reader's bookmark for no gain.
module.exports = {
tags: [
{ 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: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
],
components: {
schemas: {
// ── uo-link shard data ──────────────────────────────────────────────
UoShardStatus: {
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/UoShardEconomyPoint' },
},
},
UoShardEvent: {
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' },
},
},
UoShardEconomyPoint: {
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 },
},
},
UoShardOnlinePlayer: {
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 },
},
},
UoShardVendorSale: {
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' },
},
},
UoShardHouse: {
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' },
},
},
UoShardPointsBoard: {
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' },
},
},
UoShardMarketLocation: {
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." },
},
},
UoShardMarketListing: {
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/UoShardMarketLocation' },
updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' },
},
},
},
},
UoShardMarketPage: {
type: 'object',
description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.',
properties: {
listings: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } },
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.',
},
},
},
UoShardMarketVendor: {
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/UoShardMarketLocation' },
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/UoShardMarketListing' } },
},
},
UoShardMarketMeta: {
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'] },
},
},
UoShardFeatures: {
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'],
},
},
},
UoShardFeatureVisibility: {
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' },
},
},
},
UoShardVisibilityConfig: {
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/UoShardFeatureVisibility' },
},
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
},
},
},
UoShardVisibilityUpdate: {
type: 'object',
required: ['features'],
properties: {
features: {
type: 'object',
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
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.
UoAtlasCreature: {
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/UoAtlasSpawner' },
},
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 },
},
},
},
},
},
UoAtlasSpawner: {
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' },
},
},
UoAtlasCreaturePage: {
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/UoAtlasCreature' } },
},
},
UoAtlasRegion: {
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 },
},
},
},
UoAtlasLandmark: {
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 },
},
},
UoAtlasChampion: {
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' },
},
},
UoAtlasMeta: {
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'] },
},
},
UoAtlasStatus: {
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,
},
},
},
UoAtlasRefreshResult: {
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' } },
},
},
UoClilocStatus: {
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 },
},
},
UoClilocRefreshResult: {
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.',
},
},
},
UoShardLinkRequest: {
type: 'object',
required: ['code'],
properties: {
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
},
},
UoShardLinkResult: {
type: 'object',
properties: {
linked: { type: 'boolean', example: true },
account: { type: 'string', example: 'whitlocktech' },
},
},
UoShardLink: {
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' },
},
},
UoTownCrierRequest: {
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 },
},
},
},
},
}

View File

@@ -0,0 +1,170 @@
// The frozen manifest's derivation, checked.
//
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
// checked out beside it — so it is the least-exercised piece of machinery in this
// repo, and it is the piece that decides whether the URLs this module claims are
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
// two manifests and a fragment, so all three are asked here, with fixtures rather
// than a clone.
//
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
// 72 routes are proved by the job that generates them from a real core, and a
// copy of that count in this file would only ever be a second thing to update.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
const get = (p) => ({ method: 'GET', path: p })
test('the module\'s routes are the ones a core gains by loading it', () => {
const before = manifest([get('/api/v1/public/settings')])
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/shard/status')])
const { added, removed } = diffManifests(before, after)
assert.deepStrictEqual(removed, [])
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/shard/status', tier: 'public' }])
})
test('a route core loses to the module is reported, not quietly absorbed', () => {
// The failure this exists for: a module whose mount displaces a core route.
// It cannot show up as an addition — the URL is unchanged — so a check that
// only looked at what appeared would call this clean.
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
const after = manifest([get('/api/v1/public/settings')])
const { removed } = diffManifests(before, after)
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
})
test('a route whose METHOD changed counts as removed and added', () => {
const { added, removed } = diffManifests(
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
)
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
assert.strictEqual(added.length, 1)
})
test('the internal app is diffed too, and keeps its own tier', () => {
const { added } = diffManifests(
manifest([], [get('/internal/health')]),
manifest([], [get('/internal/health'), get('/internal/uo/thing')]),
)
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/uo/thing', tier: 'internal' }])
})
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
const { added } = diffManifests(
manifest([]),
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
)
assert.deepStrictEqual(added.map((r) => `${r.method} ${r.path}`), ['GET /a', 'GET /b', 'POST /a'])
})
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
const fragment = (paths) => ({ paths })
test('a served route with no documented operation is named', () => {
const { undocumented, unserved } = coverage(
[get('/api/v1/public/shard/status')],
fragment({}),
)
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/shard/status'])
assert.deepStrictEqual(unserved, [])
})
test('a documented operation nobody serves is named too', () => {
// The direction core's own spec has no check for, which is how it accumulated
// four orphan tags and thirty-three orphan schemas describing routes that had
// moved to this repo. A documented URL nobody serves is a client following the
// docs into a 404.
const { undocumented, unserved } = coverage(
[],
fragment({ '/api/v1/public/shard/gone': { get: {} } }),
)
assert.deepStrictEqual(undocumented, [])
assert.deepStrictEqual(unserved, ['GET /api/v1/public/shard/gone'])
})
test('express :params and OpenAPI {params} are the same route', () => {
const { undocumented, unserved } = coverage(
[{ method: 'DELETE', path: '/api/v1/admin/users/:id/shard/link/:account' }],
fragment({ '/api/v1/admin/users/{id}/shard/link/{account}': { delete: {} } }),
)
assert.deepStrictEqual(undocumented, [])
assert.deepStrictEqual(unserved, [])
})
test('methods are matched, not just paths', () => {
const { undocumented, unserved } = coverage(
[{ method: 'POST', path: '/api/v1/admin/shard/kick' }],
fragment({ '/api/v1/admin/shard/kick': { get: {} } }),
)
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/shard/kick'])
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/shard/kick'])
})
// ── the committed artifacts, against each other ─────────────────────────────
//
// These two files are generated together by a job that has a real core; here
// there is no core, so what can still be asked is whether they agree with each
// other. If they do not, one of them was committed without the other.
test('every route in the committed manifest has a committed operation', () => {
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
const { undocumented, unserved } = coverage(routes, spec)
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
})
test('the fragment carries only the three sections §6.1a allows', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
})
test('the fragment defines only namespaced schemas, and redefines none of core\'s', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
for (const name of Object.keys(spec.components.schemas)) {
assert.match(name, /^Uo[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
}
// Core's shared schemas are REFERENCED by their core names and not redefined;
// they resolve in the merged document, which is the whole point of a fragment.
const refs = JSON.stringify(spec.paths).match(/#\/components\/schemas\/([A-Za-z0-9_]+)/g) || []
const core = [...new Set(refs.map((r) => r.split('/').pop()))].filter((n) => !n.startsWith('Uo'))
assert.deepStrictEqual(core.sort(), ['Error', 'ValidationError'])
})
test('every path in the fragment is fully qualified', () => {
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
for (const p of Object.keys(spec.paths)) {
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
// a router-relative path here is a path nothing serves.
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
}
})
test('the manifest and the module\'s declared mounts agree', () => {
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
const declared = []
for (const [tier, prefixes] of Object.entries(mounts)) {
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
}
// The extension slot is core's resource, not one of our mounts (§2.4).
const slot = '/api/v1/admin/users/'
for (const route of routes) {
const under = declared.some((d) => route.path.startsWith(d)) || route.path.startsWith(slot)
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
}
})

View File

@@ -0,0 +1,92 @@
// The fragment generator's derivation, checked.
//
// `scripts/swaggerFragment.js` decides the prefix of every path core will publish
// on this module's behalf, and it decides it from `server/index.js`'s own
// registration call rather than from a table. That derivation is what these
// assert. The generator's OUTPUT — whether those prefixes are the URLs a real
// core serves — is `frozenManifest.js`'s question, because answering it needs a
// core; here the question is whether the machinery reads the module correctly.
const test = require('node:test')
const assert = require('node:assert')
const { mountedRouters, prefixPaths, TIER_BASE, SLOT_MOUNT } = require('../scripts/swaggerFragment')
test('every registered mount is discovered, and resolved to a source file', () => {
const mounts = mountedRouters()
// Six: five `registerRoutes` prefixes plus the filled extension slot.
assert.strictEqual(mounts.length, 6)
for (const { file, prefix } of mounts) {
assert.match(file, /server[\\/]router[\\/]/, 'a mounted router resolved to a file outside router/')
assert.match(prefix, /^\/api\/v1\/(public|admin|player)\//)
}
})
test('the prefixes come from the registration, not from a list here', () => {
// Change `registerRoutes` in server/index.js and this list changes with it —
// which is the property being asserted. The manifest declares the same five
// prefixes and the loader rejects a mismatch between the two, so this is the
// third and last place they could disagree.
const byPrefix = mountedRouters().map((m) => m.prefix).sort()
assert.deepStrictEqual(byPrefix, [
'/api/v1/admin/shard',
'/api/v1/admin/uo-link',
'/api/v1/admin/users/:id',
'/api/v1/player/shard',
'/api/v1/public/atlas',
'/api/v1/public/shard',
])
})
test('the tier bases and the slot mount are §2.4\'s, spelled as core mounts them', () => {
assert.deepStrictEqual(TIER_BASE, {
public: '/api/v1/public',
admin: '/api/v1/admin',
player: '/api/v1/player',
})
assert.deepStrictEqual(SLOT_MOUNT, { 'admin.users.detail': '/api/v1/admin/users/:id' })
})
test('re-rooting converts express params to OpenAPI\'s', () => {
const paths = prefixPaths({ paths: { '/shard/link': { get: {} } } }, '/api/v1/admin/users/:id')
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/admin/users/{id}/shard/link'])
})
test('re-rooting drops the trailing slash a collection route would produce', () => {
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`, a
// URL no client calls and the manifest does not record.
const paths = prefixPaths({ paths: { '/': { get: {} } } }, '/api/v1/public/shard')
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/public/shard'])
})
test('the prefix\'s own parameters are ordered ahead of the route\'s', () => {
// swagger-autogen orders parameters by where they appeared in the path it saw,
// and it only ever saw the tail — so without this every slot route would churn
// the committed fragment by a reorder that means nothing.
const paths = prefixPaths(
{
paths: {
'/shard/link/{account}': {
delete: { parameters: [{ name: 'account', in: 'path' }, { name: 'id', in: 'path' }] },
},
},
},
'/api/v1/admin/users/:id',
)
const params = paths['/api/v1/admin/users/{id}/shard/link/{account}'].delete.parameters
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'account'])
})
test('a parameter the prefix does not name keeps its position', () => {
const paths = prefixPaths(
{
paths: {
'/thing': { get: { parameters: [{ name: 'limit' }, { name: 'offset' }, { name: 'id' }] } },
},
},
'/api/v1/admin/users/:id',
)
const params = paths['/api/v1/admin/users/{id}/thing'].get.parameters
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'limit', 'offset'])
})