Merge pull request 'feat(release): ship an OpenAPI fragment, a frozen manifest and a bundle (phase 3, slice 5)' (#6) from feature/close-phase3 into main
All checks were successful
Release / release (push) Successful in 24s
All checks were successful
Release / release (push) Successful in 24s
Reviewed-on: #6
This commit is contained in:
@@ -27,11 +27,29 @@
|
|||||||
# Building the chunk in CI is not only a check: it is how the chunk that ships is
|
# Building the chunk in CI is not only a check: it is how the chunk that ships is
|
||||||
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
|
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
|
||||||
#
|
#
|
||||||
# Not here yet, deliberately, because there is nothing for them to act on until
|
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
|
||||||
# the extraction is further along: the release workflow (the
|
# module registers, today. Core has no way to generate it: core is a prebuilt
|
||||||
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
|
# image, this module arrived on a volume afterwards, and it mounts through a
|
||||||
# own frozen route manifest, which needs core checked out at a pinned ref
|
# call no static parser can follow. So the fragment core merges into
|
||||||
# (MODULE_API.md §5.3). Each lands with the slice it checks.
|
# `/api/docs.json` is whatever this repo committed, and a stale one documents
|
||||||
|
# a URL surface that does not exist (§2.8).
|
||||||
|
#
|
||||||
|
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
|
||||||
|
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
|
||||||
|
# (without this module, then with) and takes the difference. That difference
|
||||||
|
# is what this module serves, and it is checked three ways: it must match the
|
||||||
|
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
|
||||||
|
# core's own routes, and every route in it must have an operation in
|
||||||
|
# `swagger-fragment.json` — the per-module form of core's rule that a route
|
||||||
|
# which isn't in the spec doesn't ship (§5.3, §2.8).
|
||||||
|
#
|
||||||
|
# Nothing else can ask those questions. Every other check here runs against
|
||||||
|
# this repo alone, where a mount prefix is a string in `server/index.js` and a
|
||||||
|
# documented path is a string in a JSON file; whether they name the same URL
|
||||||
|
# is a fact about a running core, and this is the only job that has one.
|
||||||
|
#
|
||||||
|
# Still not here, deliberately: nothing. The release workflow is
|
||||||
|
# `.gitea/workflows/release.yml` and runs on a tag rather than on a PR.
|
||||||
#
|
#
|
||||||
# Enforcement (one-time, in the Gitea UI):
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
@@ -55,6 +73,15 @@ concurrency:
|
|||||||
group: pr-checks-${{ github.ref }}
|
group: pr-checks-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
|
||||||
|
# often enough to matter, and there are five `npm ci` calls across these jobs — a
|
||||||
|
# red X that means "the network hiccuped" costs a reviewer more than it costs the
|
||||||
|
# runner to retry, and teaches everyone to re-run rather than read a failure.
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-tests:
|
server-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -79,6 +106,9 @@ jobs:
|
|||||||
- name: Check the module boundary (MODULE_API.md §5.1)
|
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||||
run: npm run check:imports --prefix server
|
run: npm run check:imports --prefix server
|
||||||
|
|
||||||
|
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||||
|
run: npm run check:swagger --prefix server
|
||||||
|
|
||||||
client-build:
|
client-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -107,3 +137,70 @@ jobs:
|
|||||||
|
|
||||||
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||||
run: npm run check:externals --prefix client
|
run: npm run check:externals --prefix client
|
||||||
|
|
||||||
|
# ── The URLs this module actually serves ──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Everything above proves the module against itself. This proves it against a
|
||||||
|
# real core: the one place where "the prefix I register" and "the path I
|
||||||
|
# document" are the same fact rather than two strings that ought to agree.
|
||||||
|
#
|
||||||
|
# The module is COPIED into the core checkout, never symlinked — core's loader
|
||||||
|
# filters its scan with `entry.isDirectory()`, which reports a link as a link
|
||||||
|
# and skips it silently, so a symlinked module produces a manifest with no
|
||||||
|
# module routes in it and a diff that looks like the module registering
|
||||||
|
# nothing.
|
||||||
|
frozen-manifest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: module
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||||
|
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||||
|
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
|
||||||
|
run: |
|
||||||
|
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
|
||||||
|
REF=$(node -p "require('./module/ci/core-ref.json').ref")
|
||||||
|
echo "core: $REPO @ $REF"
|
||||||
|
git clone --quiet "$REPO" core
|
||||||
|
git -C core checkout --quiet "$REF"
|
||||||
|
|
||||||
|
- name: Install core's server deps
|
||||||
|
run: npm ci --prefix core/server
|
||||||
|
|
||||||
|
# Core alone. `--check` first, so a pin that no longer regenerates its own
|
||||||
|
# committed manifest fails HERE, naming the pin, instead of showing up below
|
||||||
|
# as this module having removed a route it never touched.
|
||||||
|
- name: Generate core's manifest without this module
|
||||||
|
run: |
|
||||||
|
npm run routes:manifest --prefix core/server -- --check
|
||||||
|
cp core/server/routes.manifest.json before.json
|
||||||
|
|
||||||
|
# The chunk has to exist before the loader will accept the module at all —
|
||||||
|
# `client.entry` is validated during the manifest step of the scan, and a
|
||||||
|
# missing one is a load failure, not a warning.
|
||||||
|
- name: Build the client chunk
|
||||||
|
run: |
|
||||||
|
npm ci --prefix module/client
|
||||||
|
npm run build --prefix module/client
|
||||||
|
|
||||||
|
- name: Install the module into core
|
||||||
|
run: |
|
||||||
|
mkdir -p core/modules/uo
|
||||||
|
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/uo -xf -
|
||||||
|
npm ci --omit=dev --prefix core/modules/uo/server
|
||||||
|
|
||||||
|
- name: Generate core's manifest with this module
|
||||||
|
run: |
|
||||||
|
npm run routes:manifest --prefix core/server
|
||||||
|
cp core/server/routes.manifest.json after.json
|
||||||
|
|
||||||
|
- name: Check the frozen manifest and the fragment's coverage
|
||||||
|
working-directory: module
|
||||||
|
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check
|
||||||
|
|||||||
258
.gitea/workflows/release.yml
Normal file
258
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Build and publish the installable bundle: `module-uo-<version>.tar.gz` plus the
|
||||||
|
# manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||||
|
#
|
||||||
|
# ── What a release IS here ──────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||||
|
# the whole module system is shaped around). So a release is not source: it is the
|
||||||
|
# directory core's loader expects to find at `modules/uo/`, already assembled —
|
||||||
|
# the prebuilt client chunk, the one runtime dependency installed, the schema
|
||||||
|
# fragment and the OpenAPI fragment — packed as it will be unpacked. Phase 4's
|
||||||
|
# admin install downloads the tarball, verifies it against the `sha256` in the
|
||||||
|
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||||
|
#
|
||||||
|
# ── The version is DECLARED, not derived ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read
|
||||||
|
# conventional-commit subjects to compute the next version, this repo already has
|
||||||
|
# one authoritative version — `module.json`'s, which is the version core records
|
||||||
|
# in `installed_modules` and shows on the admin screen, and which sits beside the
|
||||||
|
# `coreApi` range a bump usually has to be considered against. Two sources for one
|
||||||
|
# number is how they drift, so: **a release happens when a merge to `main` leaves
|
||||||
|
# `module.json` at a version that has no release yet.** Bumping the version is an
|
||||||
|
# ordinary reviewed PR; publishing is this file's business.
|
||||||
|
#
|
||||||
|
# It follows that this workflow never writes to a branch — it tags and publishes,
|
||||||
|
# nothing else — so `main` needs no push exception. That is the installer's model,
|
||||||
|
# adopted here for the reason it was adopted there: `main` is protected, and a
|
||||||
|
# release engine that has to push to it is a release engine that stops working the
|
||||||
|
# day someone tightens the rule.
|
||||||
|
#
|
||||||
|
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||||
|
# unrelated failure is safe.
|
||||||
|
#
|
||||||
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
|
||||||
|
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||||
|
# and create the release.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-module-uo
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
REPO: RunicGateway/Module-uo
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Decide whether this commit releases
|
||||||
|
id: plan
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="$(node -p "require('./module.json').version")"
|
||||||
|
echo "module.json version: ${VERSION}"
|
||||||
|
|
||||||
|
# Does a release already exist for this version? A 404 means no, a 200
|
||||||
|
# means yes, and anything else — a network failure, a bad token — is not
|
||||||
|
# evidence of absence. Guessing "no" would publish over a good release,
|
||||||
|
# so refuse instead. (The installer learned this one the expensive way.)
|
||||||
|
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||||
|
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||||
|
|
||||||
|
case "$HTTP" in
|
||||||
|
404) RELEASE=true ;;
|
||||||
|
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||||
|
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Before anything is built or tagged, so a repo without secrets fails
|
||||||
|
# legibly rather than half-publishing: the tag push can succeed on the
|
||||||
|
# credential actions/checkout left in the local git config while the
|
||||||
|
# release API call 401s, leaving the repo tagged and unreleased.
|
||||||
|
- name: Verify release credentials are configured
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||||
|
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release credentials present."
|
||||||
|
|
||||||
|
- name: Build the client chunk
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
npm ci --prefix client
|
||||||
|
npm run build --prefix client
|
||||||
|
|
||||||
|
# `--omit=dev` and then PACKED: express, express-validator and swagger-autogen
|
||||||
|
# are build- and test-time only — the shipped half is handed express on `ctx`
|
||||||
|
# (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves
|
||||||
|
# it by walking up from `modules/uo/server/`, which is why it ships inside the
|
||||||
|
# tarball rather than being installed on the operator's box.
|
||||||
|
- name: Install the shipped runtime dependency
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: npm ci --omit=dev --prefix server
|
||||||
|
|
||||||
|
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||||
|
#
|
||||||
|
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
|
||||||
|
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||||
|
# credential in it, an exclude list packs it and nobody finds out.
|
||||||
|
- name: Assemble the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
OUT="dist/module-uo-${VERSION}"
|
||||||
|
rm -rf dist && mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# The manifest core reads, the two fragments, and the licence the code
|
||||||
|
# is under — a bundle that ships GPL code without its licence is not
|
||||||
|
# distributable.
|
||||||
|
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||||
|
|
||||||
|
# The server half, minus what never runs inside core's process.
|
||||||
|
mkdir -p "$OUT/server"
|
||||||
|
for d in boot.js core.js index.js config data db model router utils; do
|
||||||
|
cp -r "server/$d" "$OUT/server/"
|
||||||
|
done
|
||||||
|
cp server/package.json "$OUT/server/"
|
||||||
|
cp -r server/node_modules "$OUT/server/"
|
||||||
|
|
||||||
|
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
|
||||||
|
# of source an operator has no use for and core will never read.
|
||||||
|
mkdir -p "$OUT/client/dist"
|
||||||
|
cp client/dist/entry.js "$OUT/client/dist/"
|
||||||
|
|
||||||
|
# Prove the bundle is loadable before it is published: these are the
|
||||||
|
# paths core's loader resolves out of module.json, and a release whose
|
||||||
|
# entry point is missing fails on an operator's box with a
|
||||||
|
# `startup_failed` row instead of here.
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs"), path = require("path");
|
||||||
|
const root = process.argv[1];
|
||||||
|
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||||
|
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
|
||||||
|
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log("bundle contents check: ok");
|
||||||
|
' "$OUT"
|
||||||
|
|
||||||
|
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
|
||||||
|
SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||||
|
SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")"
|
||||||
|
|
||||||
|
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||||
|
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||||
|
# the model this project already has and a second one would be a second
|
||||||
|
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||||
|
jq -n \
|
||||||
|
--arg id "$(node -p "require('./module.json').id")" \
|
||||||
|
--arg name "$(node -p "require('./module.json').name")" \
|
||||||
|
--arg version "$VERSION" \
|
||||||
|
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||||
|
--arg artifact "module-uo-${VERSION}.tar.gz" \
|
||||||
|
--arg sha256 "$SHA" \
|
||||||
|
--argjson size "$SIZE" \
|
||||||
|
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \
|
||||||
|
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||||
|
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||||
|
> "dist/module-uo-${VERSION}.json"
|
||||||
|
|
||||||
|
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||||
|
cat "dist/module-uo-${VERSION}.json"
|
||||||
|
|
||||||
|
- name: Write the changelog
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
|
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||||
|
{
|
||||||
|
echo "## module-uo v${VERSION}"
|
||||||
|
echo
|
||||||
|
echo "Install from the website's Admin → Modules screen, or unpack onto the"
|
||||||
|
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
|
||||||
|
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
|
||||||
|
echo
|
||||||
|
echo "### Changes"
|
||||||
|
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||||
|
git log --no-merges --format='- %s' $RANGE || true
|
||||||
|
echo
|
||||||
|
echo "### Verifying this download"
|
||||||
|
echo
|
||||||
|
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
|
||||||
|
echo "trust anchor, and the website verifies it before unpacking."
|
||||||
|
echo
|
||||||
|
echo '```bash'
|
||||||
|
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||||
|
echo '```'
|
||||||
|
} > dist/CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Tag the release
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
git config user.name 'Runic Gateway CI'
|
||||||
|
git config user.email 'ci@whitlocktech.net'
|
||||||
|
git tag -a "$TAG" -m "module-uo ${TAG}"
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
- name: Create the Gitea release and upload the bundle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||||
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||||
|
| jq -r '.id')"
|
||||||
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
|
for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do
|
||||||
|
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-F "attachment=@dist/${f}" >/dev/null
|
||||||
|
echo " uploaded ${f}"
|
||||||
|
done
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -19,7 +19,13 @@ client/coverage/
|
|||||||
*.env
|
*.env
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Release staging
|
# Release staging. `.gitea/workflows/release.yml` assembles the bundle under
|
||||||
|
# /dist and packs it from there. Note this is the ROOT dist only — the module's
|
||||||
|
# two committed generated artifacts, swagger-fragment.json and
|
||||||
|
# routes.manifest.json, are deliberately NOT ignored: core merges the first
|
||||||
|
# verbatim and the second is the frozen URL surface, so both have to be
|
||||||
|
# reviewable in a diff (MODULE_API.md §2.8, §5.3).
|
||||||
|
/dist/
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
# logs / os
|
# logs / os
|
||||||
|
|||||||
126
README.md
126
README.md
@@ -24,7 +24,7 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i
|
|||||||
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
|
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
|
||||||
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
|
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
|
||||||
|
|
||||||
## Status: the bundle skeleton exists; the extraction has started
|
## Status: the extraction is complete; this repo is the UO half of the site
|
||||||
|
|
||||||
The design of record is
|
The design of record is
|
||||||
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
||||||
@@ -37,32 +37,50 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe
|
|||||||
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
|
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
|
||||||
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
|
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
|
||||||
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
|
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
|
||||||
| 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress |
|
| 3 — extract the UO half of the site into this repo | `website`, here | ✅ done |
|
||||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
||||||
|
|
||||||
Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first
|
Phase 3 moved the UO half of `website/` here in six slices (`MODULE_SYSTEM.md` §2.7.1): the bundle
|
||||||
and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one
|
skeleton, the whole server half, core's client extension slots, the whole client half, the de-UO of
|
||||||
merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core
|
core's own copy, and this one — the artifacts that make the result installable and checkable. Each
|
||||||
drops it.
|
slice was one PR here that added and one in `website` that deleted, this one merging first, so
|
||||||
|
`website`'s `edge` branch served each feature from core right up to the moment core dropped it.
|
||||||
|
|
||||||
**Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the
|
Neither half sliced by feature in the end, and for the same reason on both sides: a mount prefix is
|
||||||
delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves
|
claimed whole and a shared leaf moves with its **last** consumer, so the closure of either half is
|
||||||
the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the
|
the whole half.
|
||||||
renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after
|
|
||||||
this one adds registrations to `server/index.js` and `client/src/entry.jsx`.
|
**What core serves and what this repo serves is now a fact you can read**, not a claim: 72 URLs, in
|
||||||
|
[`routes.manifest.json`](routes.manifest.json), derived by loading this module into a real core and
|
||||||
|
diffing. Not one of core's own URLs moved — that is the promise `MODULE_SYSTEM.md` §1.2 makes to the
|
||||||
|
shipped Android app and the Discord bot, and it is checked on every PR.
|
||||||
|
|
||||||
## Working on it
|
## Working on it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
|
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
|
||||||
|
npm run check:swagger --prefix server # is swagger-fragment.json still current?
|
||||||
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
|
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
|
||||||
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
|
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
|
||||||
```
|
```
|
||||||
|
|
||||||
The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests:
|
The `check:*` scripts are the contract's acceptance criteria rather than this module's own tests: no
|
||||||
no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into
|
import may escape the module root (`MODULE_API.md` §5.1), no bare specifier may survive into the
|
||||||
the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build
|
built chunk (§3.6), and the OpenAPI fragment core merges must describe the routes registered today
|
||||||
itself, from a guard inside `vite.config.js`.
|
(§2.8). The matching build failure — a shared dependency being *bundled* — comes from a guard inside
|
||||||
|
`vite.config.js`.
|
||||||
|
|
||||||
|
**Changed a route, or its `#swagger` annotations?** `npm run swagger --prefix server` regenerates
|
||||||
|
`swagger-fragment.json`; commit it. Core cannot generate it — core is a prebuilt image and this
|
||||||
|
module mounts through a call no static parser can follow — so the file this repo commits is the one
|
||||||
|
an operator's `/api/docs` shows.
|
||||||
|
|
||||||
|
**Changed a mount prefix, or added a route?** `routes.manifest.json` is regenerated by the
|
||||||
|
`frozen-manifest` CI job, which clones core at the ref pinned in [`ci/core-ref.json`](ci/core-ref.json),
|
||||||
|
loads this module into it and takes the difference. To do it locally, check this repo out into that
|
||||||
|
core as `modules/uo` (**copy it — a symlink is silently skipped by the loader**), run core's
|
||||||
|
`npm run routes:manifest` with and without it, and hand both files to
|
||||||
|
`server/scripts/frozenManifest.js`.
|
||||||
|
|
||||||
Running it against a real core means checking this repo out as `website/modules/uo`, building the
|
Running it against a real core means checking this repo out as `website/modules/uo`, building the
|
||||||
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
|
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
|
||||||
@@ -72,24 +90,35 @@ neither has a shape a DOM-less test runner can see.
|
|||||||
## What it contains
|
## What it contains
|
||||||
|
|
||||||
One repo, one bundle: the server half and the client half live side by side and version together, so
|
One repo, one bundle: the server half and the client half live side by side and version together, so
|
||||||
a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
|
a route and the screen that calls it can never be mismatched.
|
||||||
|
|
||||||
```
|
```
|
||||||
module.json ✅ id, version, coreApi range, mounts, extensions
|
module.json id, version, coreApi range, mounts, extensions
|
||||||
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
|
swagger-fragment.json generated · the OpenAPI core merges into /api/docs.json
|
||||||
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
|
routes.manifest.json generated · the 72 URLs this module serves
|
||||||
server/test/ ✅ node --test, with a fake ctx standing in for core
|
ci/core-ref.json the core commit the two above were proved against
|
||||||
server/ routers, controllers, models, utils
|
server/index.js the entry point — register(ctx, api), synchronous, no database
|
||||||
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
server/router/ routers + controllers, one directory per tier
|
||||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
server/model/ one directory per table family; nothing crosses the boundary
|
||||||
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
|
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
|
||||||
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
|
server/config/ the push stream catalog
|
||||||
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
|
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||||
client/src/ route components, nav registrations, feature provider
|
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||||
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
|
server/scripts/ the three checks: imports, the fragment, the frozen manifest
|
||||||
|
server/test/ node --test, with a fake ctx standing in for core
|
||||||
|
client/src/entry.jsx the chunk's entry — registers routes, nav, slots, feature provider
|
||||||
|
client/src/shim/ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
|
||||||
|
client/vite.config.js the library build, the aliases, the not-bundled guard
|
||||||
|
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
|
||||||
```
|
```
|
||||||
|
|
||||||
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.
|
**The three generated files are committed on purpose.** Two of them are what core reads instead of
|
||||||
|
looking at this source — it never has it — and the third records which core they were proved against.
|
||||||
|
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a
|
||||||
|
diff.
|
||||||
|
|
||||||
|
Release artifact: `module-uo-<version>.tar.gz`, plus `module-uo-<version>.json` carrying its
|
||||||
|
`sha256`. See below.
|
||||||
|
|
||||||
## How it reaches an operator
|
## How it reaches an operator
|
||||||
|
|
||||||
@@ -103,6 +132,45 @@ The [installer](https://gitea.whitlocktech.com/RunicGateway/installer) is **not*
|
|||||||
It deploys the *shard* side — the plugin overlay and the uo-link sidecar — and never contacts the
|
It deploys the *shard* side — the plugin overlay and the uo-link sidecar — and never contacts the
|
||||||
website. Module delivery is website-side only.
|
website. Module delivery is website-side only.
|
||||||
|
|
||||||
|
### Releases
|
||||||
|
|
||||||
|
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The
|
||||||
|
version is **declared**, not computed from commit subjects: `module.json`'s version is what core
|
||||||
|
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range
|
||||||
|
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it
|
||||||
|
is an ordinary reviewed PR.
|
||||||
|
|
||||||
|
Each release carries:
|
||||||
|
|
||||||
|
| Asset | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `module-uo-<version>.tar.gz` | the directory core expects at `modules/uo/` — already assembled, with the chunk built and `ws` installed |
|
||||||
|
| `module-uo-<version>.json` | id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
|
||||||
|
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||||
|
|
||||||
|
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
|
||||||
|
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
|
||||||
|
second thing to get right.
|
||||||
|
|
||||||
|
The tarball is assembled from an **include** list, never an exclude list — an exclude list ships
|
||||||
|
whatever it forgot. Tests, scripts, `client/src` and the dev dependencies are not in it.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Four, all optional, all read by this module rather than by core — which is why they are documented
|
||||||
|
here and not in core's `.env.example`. In Docker they go in the Compose `.env`, since that is what
|
||||||
|
reaches the container.
|
||||||
|
|
||||||
|
| Var | Default | What |
|
||||||
|
|---|---|---|
|
||||||
|
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
|
||||||
|
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
|
||||||
|
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
||||||
|
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
|
||||||
|
|
||||||
|
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
|
||||||
|
rest with core's `SECRET_ENC_KEY`, and write-only in the API — never returned to any client.
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
`module.json` declares a `coreApi` semver range, checked at boot against core's `MODULE_API_VERSION`.
|
`module.json` declares a `coreApi` semver range, checked at boot against core's `MODULE_API_VERSION`.
|
||||||
|
|||||||
6
ci/core-ref.json
Normal file
6
ci/core-ref.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
||||||
|
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||||
|
"ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
|
||||||
|
"refName": "edge @ phase 3 slice 4 (website#140)"
|
||||||
|
}
|
||||||
365
routes.manifest.json
Normal file
365
routes.manifest.json
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
{
|
||||||
|
"$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.",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier/:id",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/accounts",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/atlas",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/audit",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/char/:serial",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/houses",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/pages",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/roster/:account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/sales",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/vendors/:account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/visibility",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/config",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/signup-mode",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/uo-link/stream",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/accounts",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/houses",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/online",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/sales",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/users/:id/shard/standing",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/accounts",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/char/:serial",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/houses",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/roster/:account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/sales",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/player/shard/vendors/:account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/champions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/creatures",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/creatures/:slug",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/landmarks",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/meta",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/atlas/regions",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/champs",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/economy",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/feed",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/governors/:city/history",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/guilds",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/houses",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/idoc",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/meta",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/market/vendors/:serial",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/online",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/points/:system",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/presence",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/status",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/stream",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/approve",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/import",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/reject",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/ban",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/broadcast",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/import",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/kick",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/link",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/close",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/pages/:id/respond",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/shard/unban",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/uo-link/towncrier",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/account",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/shard/link",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/atlas/path",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/clilocs/path",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/visibility",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/uo-link/config",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/uo-link/signup-mode",
|
||||||
|
"tier": "public"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
158
server/package-lock.json
generated
158
server/package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-validator": "^7.2.0"
|
"express-validator": "^7.2.0",
|
||||||
|
"swagger-autogen": "^2.23.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -33,6 +34,19 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -40,6 +54,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.6",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
@@ -65,6 +86,17 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -106,6 +138,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -156,6 +195,16 @@
|
|||||||
"ms": "2.0.0"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -359,6 +408,13 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -408,6 +464,28 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -481,6 +559,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -498,6 +588,19 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -581,6 +684,19 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -624,6 +740,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -634,6 +760,16 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -867,6 +1003,19 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -931,6 +1080,13 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test --require ./test/_setup.js",
|
"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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"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.",
|
"//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": {
|
"devDependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-validator": "^7.2.0"
|
"express-validator": "^7.2.0",
|
||||||
|
"swagger-autogen": "^2.23.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.21.0"
|
"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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ shardRouter.post(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
/* #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[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -59,7 +59,7 @@ shardRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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,
|
selfShard.listAccounts,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -103,7 +103,7 @@ shardRouter.get(
|
|||||||
// #swagger.tags = ['Admin · Account']
|
// #swagger.tags = ['Admin · Account']
|
||||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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,
|
selfShard.getSales,
|
||||||
)
|
)
|
||||||
shardRouter.post(
|
shardRouter.post(
|
||||||
@@ -112,7 +112,7 @@ shardRouter.post(
|
|||||||
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
// #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 shard’s mode; the password is never stored or logged.'
|
// #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 shard’s mode; the password is never stored or logged.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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[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[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" } } } } */
|
/* #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.tags = ['Admin · Shard']
|
||||||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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,
|
modAccess,
|
||||||
shardOps.listAudit,
|
shardOps.listAudit,
|
||||||
)
|
)
|
||||||
@@ -233,7 +233,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
// #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.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.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,
|
modAccess,
|
||||||
shardOps.listHouses,
|
shardOps.listHouses,
|
||||||
)
|
)
|
||||||
@@ -253,7 +253,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
// #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.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.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" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
shardAtlas.getStatus,
|
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.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.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.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,
|
adminOnly,
|
||||||
body('force').optional().isBoolean(),
|
body('force').optional().isBoolean(),
|
||||||
validate,
|
validate,
|
||||||
@@ -277,7 +277,7 @@ shardRouter.post(
|
|||||||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
// #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.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.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,
|
adminOnly,
|
||||||
shardAtlas.approve,
|
shardAtlas.approve,
|
||||||
)
|
)
|
||||||
@@ -287,7 +287,7 @@ shardRouter.post(
|
|||||||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
// #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.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.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" } } } } */
|
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
shardAtlas.reject,
|
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.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.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.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,
|
adminOnly,
|
||||||
body('path').isString().isLength({ max: 512 }),
|
body('path').isString().isLength({ max: 512 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -322,7 +322,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
// #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.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.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" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
shardClilocs.getStatus,
|
shardClilocs.getStatus,
|
||||||
@@ -331,10 +331,10 @@ shardRouter.post(
|
|||||||
'/clilocs/import',
|
'/clilocs/import',
|
||||||
// #swagger.tags = ['Admin · Shard']
|
// #swagger.tags = ['Admin · Shard']
|
||||||
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||||
// #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.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.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.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,
|
adminOnly,
|
||||||
body('force').optional().isBoolean(),
|
body('force').optional().isBoolean(),
|
||||||
body('approve').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.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.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.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,
|
adminOnly,
|
||||||
body('path').isString().isLength({ max: 512 }),
|
body('path').isString().isLength({ max: 512 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -364,7 +364,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
// #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.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.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" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
shardVisibility.getVisibility,
|
shardVisibility.getVisibility,
|
||||||
@@ -375,8 +375,8 @@ shardRouter.put(
|
|||||||
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
// #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.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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
|
/* #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/ShardVisibilityConfig" } } } } */
|
/* #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" } } } } */
|
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
adminOnly,
|
adminOnly,
|
||||||
body('features').isObject(),
|
body('features').isObject(),
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ uoLinkRouter.get(
|
|||||||
'/signup-mode',
|
'/signup-mode',
|
||||||
// #swagger.tags = ['Admin · Shard']
|
// #swagger.tags = ['Admin · Shard']
|
||||||
// #swagger.summary = 'Get the game-account signup mode (admin only)'
|
// #swagger.summary = 'Get the game-account signup mode (admin only)'
|
||||||
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard\'s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
|
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
|
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -100,7 +100,7 @@ uoLinkRouter.post(
|
|||||||
// #swagger.tags = ['Admin · Shard']
|
// #swagger.tags = ['Admin · Shard']
|
||||||
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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[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[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" } } } } */
|
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
// #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" } } } } */
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
validate,
|
validate,
|
||||||
@@ -51,7 +51,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
// #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" } } } } */
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
validate,
|
validate,
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ shardRouter.post(
|
|||||||
// #swagger.summary = 'Link an in-game account with a one-time code'
|
// #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.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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
|
||||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
/* #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[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" } } } } */
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
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.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.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.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[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[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" } } } } */
|
/* #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.tags = ['Player · Shard']
|
||||||
// #swagger.summary = 'List the caller’s linked game accounts'
|
// #swagger.summary = 'List the caller’s linked game accounts'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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,
|
shard.listAccounts,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -109,7 +109,7 @@ shardRouter.get(
|
|||||||
// #swagger.tags = ['Player · Shard']
|
// #swagger.tags = ['Player · Shard']
|
||||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #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,
|
shard.getSales,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -118,7 +118,7 @@ shardRouter.get(
|
|||||||
// #swagger.summary = 'The caller’s own houses (home status)'
|
// #swagger.summary = 'The caller’s own houses (home status)'
|
||||||
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||||
shard.getHouses,
|
shard.getHouses,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ atlasRouter.get(
|
|||||||
requireFeature('atlas'),
|
requireFeature('atlas'),
|
||||||
// #swagger.tags = ['Public · Atlas']
|
// #swagger.tags = ['Public · Atlas']
|
||||||
// #swagger.summary = 'Search the bestiary (paginated)'
|
// #swagger.summary = 'Search the bestiary (paginated)'
|
||||||
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
|
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature’s share on it. Static content parsed from the shard’s ServUO tree — unaffected by the shard being offline.'
|
||||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
||||||
// #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['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['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.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[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" } } } } */
|
/* #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 }),
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
@@ -59,11 +59,11 @@ atlasRouter.get(
|
|||||||
requireFeature('atlas'),
|
requireFeature('atlas'),
|
||||||
// #swagger.tags = ['Public · Atlas']
|
// #swagger.tags = ['Public · Atlas']
|
||||||
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
||||||
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard’s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
// #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['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.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" } } } } */
|
/* #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 }),
|
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||||
facetParam,
|
facetParam,
|
||||||
@@ -77,10 +77,10 @@ atlasRouter.get(
|
|||||||
requireFeature('atlas'),
|
requireFeature('atlas'),
|
||||||
// #swagger.tags = ['Public · Atlas']
|
// #swagger.tags = ['Public · Atlas']
|
||||||
// #swagger.summary = 'Named regions and their rectangles'
|
// #swagger.summary = 'Named regions and their rectangles'
|
||||||
// #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.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['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.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,
|
facetParam,
|
||||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -92,10 +92,10 @@ atlasRouter.get(
|
|||||||
requireFeature('atlas'),
|
requireFeature('atlas'),
|
||||||
// #swagger.tags = ['Public · Atlas']
|
// #swagger.tags = ['Public · Atlas']
|
||||||
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
||||||
// #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.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['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.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,
|
facetParam,
|
||||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -109,7 +109,7 @@ atlasRouter.get(
|
|||||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
// #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.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.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,
|
facetParam,
|
||||||
validate,
|
validate,
|
||||||
siteMode,
|
siteMode,
|
||||||
@@ -120,8 +120,8 @@ atlasRouter.get(
|
|||||||
requireFeature('atlas'),
|
requireFeature('atlas'),
|
||||||
// #swagger.tags = ['Public · Atlas']
|
// #swagger.tags = ['Public · Atlas']
|
||||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
// #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.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,
|
siteMode,
|
||||||
atlas.getMeta,
|
atlas.getMeta,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ shardRouter.get(
|
|||||||
requireFeature('status'),
|
requireFeature('status'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
// #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,
|
shard.getStatus,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -45,10 +45,10 @@ shardRouter.get(
|
|||||||
requireFeature('activity'),
|
requireFeature('activity'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||||
// #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.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['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.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('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -60,7 +60,7 @@ shardRouter.get(
|
|||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
// #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.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 }),
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
validate,
|
validate,
|
||||||
shard.getEconomy,
|
shard.getEconomy,
|
||||||
@@ -70,7 +70,7 @@ shardRouter.get(
|
|||||||
requireFeature('presence'),
|
requireFeature('presence'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
// #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,
|
shard.getOnline,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -78,8 +78,8 @@ shardRouter.get(
|
|||||||
requireFeature('houses'),
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
// #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.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,
|
shard.getIdoc,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
@@ -137,14 +137,14 @@ shardRouter.get(
|
|||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
// #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.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,
|
shard.getHouses,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/ruleset',
|
'/ruleset',
|
||||||
requireFeature('ruleset'),
|
requireFeature('ruleset'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)'
|
// #swagger.summary = 'The shard’s published ruleset (expansion, systems, caps, limits)'
|
||||||
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
|
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
|
||||||
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||||
shard.getRuleset,
|
shard.getRuleset,
|
||||||
@@ -154,18 +154,18 @@ shardRouter.get(
|
|||||||
requireFeature('leaderboards'),
|
requireFeature('leaderboards'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
|
// #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.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,
|
shard.getPointsBoards,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/points/:system',
|
'/points/:system',
|
||||||
requireFeature('leaderboards'),
|
requireFeature('leaderboards'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'One points system\'s leaderboard'
|
// #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.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.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[400] = { description: 'Malformed system name' } */
|
||||||
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
|
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
|
||||||
shard.getPointsBoard,
|
shard.getPointsBoard,
|
||||||
@@ -181,17 +181,17 @@ shardRouter.get(
|
|||||||
marketLimiter,
|
marketLimiter,
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Search the player-vendor marketplace'
|
// #swagger.summary = 'Search the player-vendor marketplace'
|
||||||
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
|
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site’s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
|
||||||
// #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['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['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['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['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['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['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.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[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[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" } } } } */
|
/* #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.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Marketplace size, staleness and filter options'
|
// #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.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,
|
shard.getMarketMeta,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
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['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['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.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[400] = { description: 'Malformed vendor serial' } */
|
||||||
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
|
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
|
||||||
param('serial').isString().isLength({ max: 20 }),
|
param('serial').isString().isLength({ max: 20 }),
|
||||||
@@ -239,15 +239,15 @@ shardRouter.get(
|
|||||||
'/features',
|
'/features',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
// #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.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,
|
shard.getFeatures,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/stream',
|
'/stream',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
||||||
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
|
// #swagger.description = 'text/event-stream of live events. The caller’s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
|
||||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
shard.stream,
|
shard.stream,
|
||||||
)
|
)
|
||||||
|
|||||||
189
server/scripts/frozenManifest.js
Normal file
189
server/scripts/frozenManifest.js
Normal 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 }
|
||||||
281
server/scripts/swaggerFragment.js
Normal file
281
server/scripts/swaggerFragment.js
Normal 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
621
server/swagger/doc.js
Normal 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
170
server/test/frozenManifest.test.js
Normal file
170
server/test/frozenManifest.test.js
Normal 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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
92
server/test/swaggerFragment.test.js
Normal file
92
server/test/swaggerFragment.test.js
Normal 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'])
|
||||||
|
})
|
||||||
8160
swagger-fragment.json
Normal file
8160
swagger-fragment.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user