feat(server): register the routes, the slot, the leg and the boot hooks
The entry point becomes real: five mount prefixes, the admin.users.detail extension slot, the shard push catalog, the town-crier announce leg and both lifecycle hooks. module.json declares all of it and the loader checks the declaration against what register() actually registers, in both directions. The URLs are byte-identical to the ones core served before the extraction. That is the whole point of moving the code and not the paths: the shipped Android app calls POST /api/v1/admin/shard/kick and the Discord bot reads /api/v1/public/shard/*, and neither knows a module answers now. Require order is load-bearing and the requires are inside register() because of it. Every ported file reaches core through ./core, whose members resolve ctx when called -- but a router does `const express = core.express` at ITS file scope, which runs the moment it is required. Hoisting these to the top of the file breaks the module with an error about ctx being missing, from a file that never mentions it. boot.js takes the eight UO call sites out of core's server.js. One behavioural change, deliberate: uoLinkSocket.start() and the sidecar health probe used to run AFTER the listener bound and now run before it, because onBoot does. start() returns as soon as the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is diagnostics; being up is not a precondition for serving a page. router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit, core's factory. The policy is the module's -- only the module knows what its endpoints cost -- and the plumbing is core's, so there is one express-rate-limit in the process and one place a breach is logged. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
134
server/router/public/atlas.controller.js
Normal file
134
server/router/public/atlas.controller.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Public: the spawn atlas ────────────────────────────────────────────────
|
||||
//
|
||||
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
|
||||
// where, how many, and which champion altars are configured. Everything here is
|
||||
// a plain indexed read of the tables the boot-time import fills from the shard's
|
||||
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// Two properties separate this from /public/shard/*:
|
||||
//
|
||||
// • **Nothing touches the sidecar.** The atlas is static shard content, not
|
||||
// live shard state, so these pages stay fully populated while the shard is
|
||||
// down. That is why the routes are mounted at /public/atlas and are
|
||||
// siteMode-gated like /posts and /wiki, rather than under /shard.
|
||||
// • **The live champion feed is a different thing.** `/atlas/champions` is the
|
||||
// configured roster ("there is an Unholy Terror altar in Deceit");
|
||||
// `/shard/champs` is the running state ("it is on level 3 right now").
|
||||
//
|
||||
// Every response is still passed through `projectFeature` for the `atlas`
|
||||
// feature. It declares no sensitive fields today, so the projection is a
|
||||
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
|
||||
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||
// rather than a retrofit the first time a field needs gating.
|
||||
|
||||
const atlas = require('../../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../../utils/shardVisibility')
|
||||
|
||||
const log = require('../../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
// Query params arrive as strings; express-validator has already bounded them.
|
||||
const int = (value, fallback) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||
async function getCreatures(req, res) {
|
||||
try {
|
||||
const page = await atlas.searchCreatures({
|
||||
q: str(req.query.q),
|
||||
facet: str(req.query.facet),
|
||||
limit: int(req.query.limit, 50),
|
||||
offset: int(req.query.offset, 0),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, page, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||
//
|
||||
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||
// specific to say to an anonymous caller.
|
||||
async function getCreature(req, res) {
|
||||
try {
|
||||
const creature = await atlas.getCreature(req.params.slug, {
|
||||
facet: str(req.query.facet),
|
||||
points: int(req.query.points, 200),
|
||||
})
|
||||
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||
return res.json(await visibility.project(FEATURE, creature, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreature', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/regions?facet=&q=
|
||||
async function getRegions(req, res) {
|
||||
try {
|
||||
const regions = await atlas.listRegions({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, regions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getRegions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/landmarks?facet=&q=
|
||||
async function getLandmarks(req, res) {
|
||||
try {
|
||||
const landmarks = await atlas.listLandmarks({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getLandmarks', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||
async function getChampions(req, res) {
|
||||
try {
|
||||
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||
return res.json(await visibility.project(FEATURE, champions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getChampions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||
//
|
||||
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||
// hashes and the pending-refresh state, all of which describe the operator's
|
||||
// filesystem rather than the game world. The admin status route carries those.
|
||||
async function getMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreatures,
|
||||
getCreature,
|
||||
getRegions,
|
||||
getLandmarks,
|
||||
getChampions,
|
||||
getMeta,
|
||||
}
|
||||
129
server/router/public/atlas.router.js
Normal file
129
server/router/public/atlas.router.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
|
||||
// the shard's own ServUO tree, not live shard state.
|
||||
//
|
||||
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
|
||||
// from the /public/shard routes next door (docs/link/v3.md §6):
|
||||
//
|
||||
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
|
||||
// stay fully populated while the shard is down. Mounting it under /shard
|
||||
// would imply a dependency it does not have.
|
||||
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
|
||||
// because shard status is wanted *during* maintenance; a bestiary is site
|
||||
// content and follows site content's rules.
|
||||
//
|
||||
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
|
||||
// disabled the feature, 403 when the caller sits below its configured audience.
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { param, query } = core.validator
|
||||
|
||||
const atlas = require('./atlas.controller')
|
||||
const { siteMode, validate } = core.middleware
|
||||
const { requireFeature } = require('../../utils/shardVisibility')
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
// Facet names come from the shard's own files and are never validated against a
|
||||
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
|
||||
// bounded, and the query matches exactly, so an unknown name returns an empty
|
||||
// result rather than an error.
|
||||
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
|
||||
|
||||
atlasRouter.get(
|
||||
'/creatures',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #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.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['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
facetParam,
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreatures,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/creatures/:slug',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #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.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||
facetParam,
|
||||
query('points').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreature,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/regions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #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.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getRegions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/landmarks',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #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.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getLandmarks,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/champions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||
facetParam,
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getChampions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/meta',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||
siteMode,
|
||||
atlas.getMeta,
|
||||
)
|
||||
|
||||
module.exports = atlasRouter
|
||||
422
server/router/public/shard.controller.js
Normal file
422
server/router/public/shard.controller.js
Normal file
@@ -0,0 +1,422 @@
|
||||
// ── Public: shard live data ────────────────────────────────────────────────
|
||||
//
|
||||
// Same-origin, token-free read endpoints backed by the data the WS ingest
|
||||
// pipeline persists (shard_online / shard_events / shard_economy / shard_houses)
|
||||
// plus a live character round-trip to the sidecar. The browser never sees the
|
||||
// sidecar URL or token — every sidecar call is server-side (uoLinkClient).
|
||||
//
|
||||
// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the
|
||||
// running shard, so it is briefly cached and degrades gracefully: a 503 (shard
|
||||
// restarting) surfaces as a retry-able banner rather than an error.
|
||||
|
||||
const shardEvents = require('../../model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const shardMarket = require('../../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcast = require('../../utils/shardBroadcast')
|
||||
const visibility = require('../../utils/shardVisibility')
|
||||
|
||||
const log = require('../../core').logger('public-shard')
|
||||
|
||||
// GET /public/shard/status — connection state + online count + latest economy.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
const [online, economy] = await Promise.all([
|
||||
shardState.onlineCount(),
|
||||
shardState.latestEconomy(),
|
||||
])
|
||||
return res.json({
|
||||
enabled: config.enabled,
|
||||
status: config.status,
|
||||
pluginConnected: config.pluginConnected,
|
||||
lastEventAt: config.lastEventAt,
|
||||
onlineCount: online,
|
||||
economy,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('shard.getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log.
|
||||
//
|
||||
// This is the stored-history twin of the SSE stream, and it must reach the same
|
||||
// verdict the stream does about the same event. Two things are therefore resolved
|
||||
// against the LIVE config rather than the compiled defaults:
|
||||
//
|
||||
// • which kinds this viewer may read at all — `visibleKinds`, not the static
|
||||
// PUBLIC_KINDS set (which is fixed at module load, so an admin moving
|
||||
// `guilds` to `staff` would gate /guilds while /feed kept serving
|
||||
// guild.join to anonymous callers), and
|
||||
// • the payload itself, projected per event against ITS OWN kind's feature —
|
||||
// the rows are a mix of features, and without this the stored frames were
|
||||
// returned verbatim, `acct`/`webId` and all, on an anonymous endpoint.
|
||||
async function getFeed(req, res) {
|
||||
try {
|
||||
const config = await visibility.getConfig()
|
||||
const level = req.viewerLevel || (await visibility.viewerLevel(req))
|
||||
const allowed = new Set(visibility.visibleKinds(level, config))
|
||||
|
||||
const { kind, limit } = req.query
|
||||
// No readable kinds ⇒ nothing to serve. Returning early also keeps us clear
|
||||
// of `list({ kinds: [] })`, which means "no filter", not "match nothing".
|
||||
if (allowed.size === 0) return res.json([])
|
||||
|
||||
let events
|
||||
if (kind) {
|
||||
if (!allowed.has(kind)) return res.json([])
|
||||
events = await shardEvents.list({ kind, limit })
|
||||
} else {
|
||||
events = await shardEvents.list({ kinds: [...allowed], limit })
|
||||
}
|
||||
|
||||
return res.json(
|
||||
events.map((ev) => ({
|
||||
...ev,
|
||||
payload: visibility.projectFeature(
|
||||
visibility.KIND_FEATURE.get(ev.kind),
|
||||
ev.payload,
|
||||
level,
|
||||
config,
|
||||
),
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('shard.getFeed', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/economy — gold-supply series, oldest → newest.
|
||||
async function getEconomy(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listEconomy(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('shard.getEconomy', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/online — players online now whose account is linked to a
|
||||
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
|
||||
// is online (name + serial); their in-game location (map + coordinates) is gated
|
||||
// on the `presence` feature's `location` field rule, which defaults to `staff`
|
||||
// — the same admin/moderator set this used to hardcode. Non-staff players are
|
||||
// never listed.
|
||||
async function canSeeStaffLocation(req) {
|
||||
const config = await visibility.getConfig()
|
||||
const required = config.presence?.fields?.location || 'staff'
|
||||
const level = req.viewerLevel || (await visibility.viewerLevel(req))
|
||||
return visibility.meets(level, required)
|
||||
}
|
||||
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const rows = await shardState.listOnlineLinked()
|
||||
const showLocation = await canSeeStaffLocation(req)
|
||||
return res.json(
|
||||
rows.map((r) => {
|
||||
const entry = { serial: r.serial, name: r.name }
|
||||
if (showLocation) {
|
||||
entry.map = r.map
|
||||
entry.x = r.x
|
||||
entry.y = r.y
|
||||
entry.z = r.z
|
||||
}
|
||||
return entry
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('shard.getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||
//
|
||||
// Projected: shapeHouse flattens the owner actor into `ownerSerial`/`ownerAcct`/
|
||||
// `ownerName`, so this endpoint used to hand an anonymous caller the house
|
||||
// owner's GAME ACCOUNT NAME. The public IDOC board only ever needed name, region
|
||||
// and location — which is all that survives projection below `staff`.
|
||||
async function getIdoc(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('houses', await shardState.listIdoc(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getIdoc', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/champs — the current champion-spawn board (all categories).
|
||||
// Served from our own store; live deltas (champ.update / champ.remove) arrive on
|
||||
// the public SSE stream so the page can update in place.
|
||||
async function getChamps(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('champs', await shardState.listChamps(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getChamps', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/guilds — the current guild board. Served from our store;
|
||||
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
||||
//
|
||||
// Projected: the stored payload is the raw guild.update frame, whose `leader`
|
||||
// actor carries `acct` and `webId`. Those are admin-only and were previously
|
||||
// returned verbatim to anonymous callers.
|
||||
async function getGuilds(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGuilds', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||
// without City Loyalty). Live via city.update on the public SSE stream. Projected
|
||||
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
|
||||
async function getGovernors(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernors', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors/:city/history — the term ledger for one city
|
||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||
async function getGovernorHistory(req, res) {
|
||||
try {
|
||||
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
|
||||
return res.json(await visibility.project('governors', terms, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernorHistory', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/presence — the online-population aggregate (count + per-facet
|
||||
// + per-region). Live via presence.online on the public SSE stream.
|
||||
async function getPresence(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('presence', await shardState.latestPresence(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPresence', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
|
||||
// their location (name + region + map/coords). Owner, price, co-owners and decay
|
||||
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
|
||||
// on the public SSE stream. This is the "where are the falling houses" board.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const idoc = await shardState.listIdoc()
|
||||
const publicHouses = idoc.map((h) => ({
|
||||
serial: h.serial,
|
||||
name: h.name,
|
||||
region: h.region,
|
||||
map: h.map,
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
z: h.z,
|
||||
isIdoc: true,
|
||||
}))
|
||||
// Already a hand-picked safe subset; projected anyway so an admin who
|
||||
// tightens a `houses` field rule sees it honoured on every houses surface
|
||||
// rather than on some of them.
|
||||
return res.json(await visibility.project('houses', publicHouses, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/ruleset — the shard's published ruleset (Protocol 3.0):
|
||||
// expansion, which optional systems are on, skill/stat caps, account and house
|
||||
// limits, champion scroll rules, the save/restart schedule. Served from our own
|
||||
// store, so it renders while the shard is down; live via world.ruleset on the
|
||||
// public SSE stream.
|
||||
//
|
||||
// `null` means the shard has never published one (an old plugin, or
|
||||
// Bridge.RulesetEnabled=false) — a real answer, distinct from a published
|
||||
// ruleset, and the page says so rather than rendering an empty one.
|
||||
//
|
||||
// Projected like every other shard read (§3.6.1's rule: a read path that returns
|
||||
// shard data and does not call projectFeature is a bug). The `connect` string is
|
||||
// the one configurable field — an operator who published a connect address may
|
||||
// still want it behind a login.
|
||||
async function getRuleset(req, res) {
|
||||
try {
|
||||
const ruleset = await shardState.getRuleset()
|
||||
if (!ruleset) return res.json(null)
|
||||
return res.json(await visibility.project('ruleset', ruleset, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getRuleset', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// The shard keys boards by its own PointsType enum name (QueensLoyalty,
|
||||
// CleanUpBritannia, …). Constrain the path param to that shape before it reaches
|
||||
// the model: the column is VARCHAR(48), and an unbounded string here is a needless
|
||||
// query on a value that can only ever be an identifier.
|
||||
const SYSTEM_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/
|
||||
|
||||
// GET /public/shard/points — every points/loyalty leaderboard the shard publishes.
|
||||
// Served from our own store, so the page renders while the shard is down — which
|
||||
// matters more here than for live state: these are standings accumulated over
|
||||
// months, and blanking them during a restart would look like a data loss.
|
||||
async function getPointsBoards(req, res) {
|
||||
try {
|
||||
const boards = await shardState.listPointsBoards()
|
||||
return res.json(await visibility.project('leaderboards', boards, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPointsBoards', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/points/:system — one system's board.
|
||||
//
|
||||
// 404 for a system the shard has never published, matching the sidecar: "no such
|
||||
// board" and "a board nobody is on yet" are different answers.
|
||||
async function getPointsBoard(req, res) {
|
||||
const { system } = req.params
|
||||
if (!SYSTEM_RE.test(system)) return res.status(400).json({ message: 'Invalid points system.' })
|
||||
try {
|
||||
const board = await shardState.getPointsBoard(system)
|
||||
if (!board) return res.status(404).json({ message: 'Unknown points system.' })
|
||||
return res.json(await visibility.project('leaderboards', board, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPointsBoard', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Marketplace (Protocol 3.0 vendor.listing) ──────────────────────────────
|
||||
//
|
||||
// The shard-wide player-vendor index. Served entirely from our own tables — the
|
||||
// sidecar is never touched on this path — so shops stay browsable while the shard
|
||||
// is down, labelled with how stale they may be.
|
||||
//
|
||||
// The staleness label is not decoration. The shard sweeps vendors round-robin, so
|
||||
// a shop can legitimately be a full cycle behind; a page that implied live prices
|
||||
// would send people to a vendor whose item sold twenty minutes ago.
|
||||
|
||||
// The serial spelling the bridge uses everywhere: "0x" and hex. Constrained
|
||||
// before it reaches the model, like SYSTEM_RE above.
|
||||
const SERIAL_RE = /^0x[0-9A-Fa-f]{1,16}$/
|
||||
|
||||
const intParam = (value) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
// GET /public/shard/market — search the index.
|
||||
//
|
||||
// Returns LISTINGS, not vendors: "who sells a vanquishing kryss and for how much"
|
||||
// is the question, and a vendor-shaped result would make every caller flatten the
|
||||
// shops back out.
|
||||
async function getMarket(req, res) {
|
||||
try {
|
||||
const page = await shardMarket.search({
|
||||
q: typeof req.query.q === 'string' ? req.query.q : '',
|
||||
minPrice: intParam(req.query.minPrice),
|
||||
maxPrice: intParam(req.query.maxPrice),
|
||||
itemId: intParam(req.query.itemId),
|
||||
map: typeof req.query.map === 'string' ? req.query.map : '',
|
||||
region: typeof req.query.region === 'string' ? req.query.region : '',
|
||||
sort: typeof req.query.sort === 'string' ? req.query.sort : 'price_asc',
|
||||
limit: intParam(req.query.limit) ?? 50,
|
||||
offset: intParam(req.query.offset) ?? 0,
|
||||
})
|
||||
return res.json(await visibility.project('market', page, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getMarket', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/market/meta — index size, staleness, and the filter options
|
||||
// (which facets and regions actually hold vendors). Separate from the search so
|
||||
// the page can build its filters without running a query it will throw away.
|
||||
async function getMarketMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('market', await shardMarket.meta(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getMarketMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/market/vendors/:serial — one shop and its listings.
|
||||
//
|
||||
// 404 for a serial the index has never seen, which also covers a vendor that has
|
||||
// since been dismissed or hidden: to an anonymous caller "no such shop" is the
|
||||
// only honest answer, and distinguishing the two would leak that a vendor exists
|
||||
// but was hidden.
|
||||
async function getMarketVendor(req, res) {
|
||||
const { serial } = req.params
|
||||
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid vendor serial.' })
|
||||
try {
|
||||
const vendor = await shardMarket.getVendor(serial, {
|
||||
limit: intParam(req.query.limit) ?? 250,
|
||||
offset: intParam(req.query.offset) ?? 0,
|
||||
})
|
||||
if (!vendor) return res.status(404).json({ message: 'Unknown vendor.' })
|
||||
return res.json(await visibility.project('market', vendor, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getMarketVendor', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/features — the shard features THIS caller can actually see,
|
||||
// so the SPA (and the Android client) can hide nav entries instead of rendering
|
||||
// links that 403. Deliberately reports only what the viewer may reach: the list
|
||||
// itself must not disclose the existence of a feature they're gated out of.
|
||||
async function getFeatures(req, res) {
|
||||
try {
|
||||
const config = await visibility.getConfig()
|
||||
const level = await visibility.viewerLevel(req)
|
||||
return res.json({ level, features: visibility.visibleFeatures(level, config) })
|
||||
} catch (err) {
|
||||
log.error('shard.getFeatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/stream — live-event SSE channel. What arrives depends on the
|
||||
// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js.
|
||||
function stream(req, res) {
|
||||
return broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
getFeed,
|
||||
getEconomy,
|
||||
getOnline,
|
||||
getIdoc,
|
||||
getChamps,
|
||||
getGuilds,
|
||||
getGovernors,
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
getHouses,
|
||||
getRuleset,
|
||||
getPointsBoards,
|
||||
getPointsBoard,
|
||||
getMarket,
|
||||
getMarketMeta,
|
||||
getMarketVendor,
|
||||
getFeatures,
|
||||
stream,
|
||||
}
|
||||
255
server/router/public/shard.router.js
Normal file
255
server/router/public/shard.router.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// Public · Shard — token-free, same-origin reads of the live shard. The
|
||||
// status/feed/economy/idoc/champs/guilds/governors/presence/houses endpoints read
|
||||
// the site's own ingested data; nothing here round-trips the sidecar per request.
|
||||
//
|
||||
// Mounted at /api/v1/public/shard by public/index.js. Deliberately NOT site-mode
|
||||
// gated — shard status is useful (and wanted) while the site itself is in
|
||||
// maintenance.
|
||||
//
|
||||
// **GET /shard/stream stays anonymous.** It is consumed by logged-out browser
|
||||
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
||||
// Authorization header; adding requireAuth here blacks out the public live boards
|
||||
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
||||
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
||||
// that per-frame filtering is the security boundary, not this file. /stream is
|
||||
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
|
||||
// each frame is gated individually against the subscriber's rung.
|
||||
//
|
||||
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
|
||||
// which 404s when an admin has disabled the feature and 403s when the caller sits
|
||||
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
|
||||
// these gates are inert until an admin changes something.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { param, query } = core.validator
|
||||
|
||||
const shard = require('./shard.controller')
|
||||
const { validate } = core.middleware
|
||||
const { marketLimiter } = require('../rateLimits')
|
||||
const { requireFeature } = require('../../utils/shardVisibility')
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
shardRouter.get(
|
||||
'/status',
|
||||
requireFeature('status'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||
shard.getStatus,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/feed',
|
||||
requireFeature('activity'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
||||
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getFeed,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/economy',
|
||||
requireFeature('status'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
||||
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getEconomy,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/online',
|
||||
requireFeature('presence'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/idoc',
|
||||
requireFeature('houses'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
|
||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getIdoc,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/champs',
|
||||
requireFeature('champs'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
||||
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getChamps,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/guilds',
|
||||
requireFeature('guilds'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
||||
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGuilds,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/governors',
|
||||
requireFeature('governors'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
||||
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGovernors,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/governors/:city/history',
|
||||
requireFeature('governors'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Governor term history for a city'
|
||||
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
||||
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
param('city').isString().isLength({ min: 1, max: 40 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||
validate,
|
||||
shard.getGovernorHistory,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/presence',
|
||||
requireFeature('presence'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
||||
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
shard.getPresence,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/houses',
|
||||
requireFeature('houses'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
||||
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/ruleset',
|
||||
requireFeature('ruleset'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||
shard.getRuleset,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/points',
|
||||
requireFeature('leaderboards'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
|
||||
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
|
||||
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
|
||||
shard.getPointsBoards,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/points/:system',
|
||||
requireFeature('leaderboards'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'One points system\'s leaderboard'
|
||||
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
|
||||
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Malformed system name' } */
|
||||
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
|
||||
shard.getPointsBoard,
|
||||
)
|
||||
// ── Marketplace ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rate-limited, unlike every other route in this file. These are the first
|
||||
// genuinely expensive PUBLIC reads on the site — a LIKE scan plus a COUNT over
|
||||
// what is typically the largest shard_* table, reachable with no session.
|
||||
shardRouter.get(
|
||||
'/market',
|
||||
requireFeature('market'),
|
||||
marketLimiter,
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' }
|
||||
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
|
||||
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
|
||||
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
|
||||
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
query('minPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||
query('maxPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||
query('itemId').optional({ values: 'falsy' }).isInt({ min: 0, max: 65535 }),
|
||||
query('map').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
|
||||
query('region').optional({ values: 'falsy' }).isString().isLength({ max: 80 }),
|
||||
query('sort').optional({ values: 'falsy' }).isIn(['price_asc', 'price_desc', 'recent']),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
shard.getMarket,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/market/meta',
|
||||
requireFeature('market'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Marketplace size, staleness and filter options'
|
||||
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
|
||||
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
|
||||
shard.getMarketMeta,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/market/vendors/:serial',
|
||||
requireFeature('market'),
|
||||
marketLimiter,
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'One player vendor and everything it is selling'
|
||||
// #swagger.description = 'A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.'
|
||||
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Malformed vendor serial' } */
|
||||
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
|
||||
param('serial').isString().isLength({ max: 20 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
shard.getMarketVendor,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/features',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
||||
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
|
||||
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
|
||||
shard.getFeatures,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||
shard.stream,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
Reference in New Issue
Block a user