Both places this site already knew an item's (ItemID, hue) and could only print it as text now show the picture, hued the way the client would draw it. The shard does the hueing: whether a hue repaints every pixel or only the grey ones is a flag in `tiledata.mul`, which a browser has no way to read. **Ingest warms; the route only serves** (org lead, 2026-09-11). A page never waits on the shard and never causes a fetch -- it renders what is stored and leaves out what is not, which is the state every install was in before this phase. Fetching happens behind that, on a timer, from the keys the site's own rows name. The alternative, fetching on first request, was rejected on one number: the shard's asset plane serves ONE request at a time, so a URL that fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot and park an operator's own import behind it. The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so it is self-healing: a restart loses nothing, and a key stops being wanted the moment the vendor row naming it is deleted. The in-memory hint set on top is only for the character sheet, which is fetched live from the shard and stored nowhere -- nothing on disk would ever name those keys. Staleness without a manifest (§7): every row records the shard's `catalog` id, a hash of the files that decide its bytes. A client patch changes it and a restart does not, so "is this out of date?" is a per-row question -- and pictures nobody looks at any more are simply never re-fetched, which is why this is lazy rather than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body catalogue's singleton, and a warm pass touching it would tell the body import that a client it never looked at is unchanged. A key the shard has no art for writes no row at all. An empty row would make the key held and it would never be asked again -- including after the operator patches in the graphic that was missing. `assets.sources` now reports which families an overlay serves, so an overlay older than phase 5 is one reported state with a sentence naming the fix, instead of a refusal per pass forever with no picture ever appearing. 688 server tests pass (14 new); client builds; the frozen manifest regenerates with one added route, all documented, no core URL moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
448 lines
34 KiB
JavaScript
448 lines
34 KiB
JavaScript
// Admin · Shard — everything under /api/v1/admin/shard, in two tiers.
|
||
//
|
||
// Mounted at /api/v1/admin/shard by admin/index.js, which already applied
|
||
// `noindex, isLoggedIn, staffOnly`. Two capabilities share this prefix, and
|
||
// prefix ownership is the invariant the split preserves — so they share a file:
|
||
//
|
||
// 1. Self-service game-account linking (no extra gate). A staff member links
|
||
// and inspects their OWN in-game account exactly as a player does under
|
||
// /player/shard; the handlers are the very same `player/shard.controller`
|
||
// ones, keyed off req.user.id. These keep their `Admin · Account` swagger
|
||
// tag, which is why the tag disagrees with this filename.
|
||
// 2. Privileged live-shard operations and the help-page queue (`modAccess` —
|
||
// admin or moderator). `actor` is stamped server-side from the session in
|
||
// shardOps.controller.js; the request body never carries it.
|
||
//
|
||
// `modAccess` stays a per-route gate rather than a router-level `use`: it was
|
||
// per-route in admin.routes.js, and half the routes here must NOT have it.
|
||
//
|
||
// NOTE: /admin/shard/pages is the in-game help-page (support) queue. It is
|
||
// unrelated to /admin/pages, the CMS page builder.
|
||
|
||
const core = require('../../core')
|
||
|
||
const express = core.express
|
||
const { body, param } = core.validator
|
||
|
||
const shardOps = require('./shardOps.controller')
|
||
const shardVisibility = require('./shardVisibility.controller')
|
||
const shardAtlas = require('./shardAtlas.controller')
|
||
const shardClilocs = require('./shardClilocs.controller')
|
||
const shardAssets = require('./shardAssets.controller')
|
||
const selfShard = require('../player/shard.controller')
|
||
const { requireRole, validate } = core.middleware
|
||
|
||
const shardRouter = express.Router()
|
||
|
||
// Moderator gate. Admins can do everything a moderator can.
|
||
const modAccess = requireRole('admin', 'moderator')
|
||
// Admin-only gate, for settings that decide what the PUBLIC sees.
|
||
const adminOnly = requireRole('admin')
|
||
|
||
// ── Game account linking (self-service, any staff role) ───────────────
|
||
// Staff link their OWN in-game account here, exactly like players do under
|
||
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||
shardRouter.post(
|
||
'/link',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||
validate,
|
||
selfShard.link,
|
||
)
|
||
shardRouter.get(
|
||
'/accounts',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||
selfShard.listAccounts,
|
||
)
|
||
shardRouter.get(
|
||
'/roster/:account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('account').matches(SHARD_ACCOUNT_RE),
|
||
validate,
|
||
selfShard.roster,
|
||
)
|
||
shardRouter.get(
|
||
'/vendors/:account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('account').matches(SHARD_ACCOUNT_RE),
|
||
validate,
|
||
selfShard.vendors,
|
||
)
|
||
shardRouter.get(
|
||
'/char/:serial',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||
validate,
|
||
selfShard.getChar,
|
||
)
|
||
shardRouter.get(
|
||
'/sales',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||
selfShard.getSales,
|
||
)
|
||
shardRouter.post(
|
||
'/account',
|
||
// #swagger.tags = ['Admin · Account']
|
||
// #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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
|
||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||
validate,
|
||
selfShard.createGameAccount,
|
||
)
|
||
|
||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||
// the controller — the body never carries it. See shardOps.controller.js.
|
||
shardRouter.post(
|
||
'/kick',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
modAccess,
|
||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||
validate,
|
||
shardOps.kick,
|
||
)
|
||
shardRouter.post(
|
||
'/ban',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
modAccess,
|
||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||
validate,
|
||
shardOps.ban,
|
||
)
|
||
shardRouter.post(
|
||
'/unban',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
modAccess,
|
||
body('account').matches(SHARD_ACCOUNT_RE),
|
||
validate,
|
||
shardOps.unban,
|
||
)
|
||
shardRouter.post(
|
||
'/broadcast',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
modAccess,
|
||
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||
validate,
|
||
shardOps.broadcast,
|
||
)
|
||
shardRouter.get(
|
||
'/pages',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||
modAccess,
|
||
shardOps.listPages,
|
||
)
|
||
shardRouter.post(
|
||
'/pages/:id/respond',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
modAccess,
|
||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||
body('close').optional().isBoolean(),
|
||
validate,
|
||
shardOps.respondPage,
|
||
)
|
||
shardRouter.post(
|
||
'/pages/:id/close',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||
modAccess,
|
||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||
validate,
|
||
shardOps.closePage,
|
||
)
|
||
shardRouter.get(
|
||
'/audit',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
|
||
modAccess,
|
||
shardOps.listAudit,
|
||
)
|
||
shardRouter.get(
|
||
'/houses',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||
modAccess,
|
||
shardOps.listHouses,
|
||
)
|
||
|
||
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||
// on the server's filesystem and replaces every atlas table, which is closer to
|
||
// a deploy action than to moderation.
|
||
//
|
||
// These routes sit under /admin/shard even though the public ones deliberately
|
||
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||
// says "this data does not come from the sidecar", while the admin panel is
|
||
// simply part of shard administration and belongs beside the rest of it.
|
||
shardRouter.get(
|
||
'/atlas',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
shardAtlas.getStatus,
|
||
)
|
||
shardRouter.post(
|
||
'/atlas/import',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||
adminOnly,
|
||
body('force').optional().isBoolean(),
|
||
validate,
|
||
shardAtlas.importAtlas,
|
||
)
|
||
shardRouter.post(
|
||
'/atlas/approve',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||
adminOnly,
|
||
shardAtlas.approve,
|
||
)
|
||
shardRouter.post(
|
||
'/atlas/reject',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
shardAtlas.reject,
|
||
)
|
||
shardRouter.put(
|
||
'/atlas/path',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */
|
||
adminOnly,
|
||
body('path').isString().isLength({ max: 512 }),
|
||
validate,
|
||
shardAtlas.setPath,
|
||
)
|
||
|
||
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||
// UO's id → display-string map, read from the shard's own UO client over the
|
||
// bridge (docs/link/v8.md §9, docs/website/CLILOCS.md). Sits beside the atlas for
|
||
// the same reason: it is static content derived from the operator's own files
|
||
// rather than anything the sidecar streams, and operating it is shard
|
||
// administration.
|
||
//
|
||
// Protocol 8 changed where the base table comes from, not what these routes are:
|
||
// the shard decompresses `Cliloc.enu` and serves it paged, so an operator no
|
||
// longer converts anything by hand. Import stays an explicit admin action,
|
||
// because the only thing that changes a client's table is an operator patching
|
||
// their client.
|
||
//
|
||
// There is deliberately NO public counterpart. The table is never served as a
|
||
// table — 123k rows would dwarf any page that used it, and the Android client
|
||
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||
// responses that need them.
|
||
shardRouter.get(
|
||
'/clilocs',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #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 they have drifted from what is loaded. `source` says which pipeline is in use: `bridge` (the shard reads its own client — the normal case once uo-link is configured) or `file` (a converted file on disk, deprecated, kept for installs with no shard link). On the bridge, `shard` carries the client file’s size, mtime, hash and the shard’s extractor version, and `shard.hashing: true` means a null hash is “not computed yet”, not “changed”. The table is always a SET: the base plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any overlay that was loaded before and is now gone; an import refuses that without `approve`. A shard with no source at all is a supported state — item names simply render as ids.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
shardClilocs.getStatus,
|
||
)
|
||
shardRouter.post(
|
||
'/clilocs/import',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Re-import the cliloc table from its source (admin only)'
|
||
// #swagger.description = 'Applies a client patch, or a change to the shard’s own overlay files, without a restart. On the bridge this is the ONLY thing that imports — boot deliberately does not call the shard — so it is what an operator presses after patching their client. `force` reimports even when the sources are unchanged. `approve` accepts a refresh in which a previously-loaded overlay 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. Nothing here throws for an operator-visible problem: a shard that is down, an asset plane the operator has switched off, a client with no cliloc file, or a malformed overlay all answer 200 with status "unavailable" and a reason naming what to fix.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocRefreshResult" } } } } */
|
||
adminOnly,
|
||
body('force').optional().isBoolean(),
|
||
body('approve').optional().isBoolean(),
|
||
validate,
|
||
shardClilocs.importClilocs,
|
||
)
|
||
shardRouter.put(
|
||
'/clilocs/path',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Set the cliloc path the site reads overlays (and any file base) from (admin only)'
|
||
// #swagger.description = 'On an install with uo-link configured this selects only where `custom/` overlays are read from — the base table comes from the shard. Without a shard link it is also where the converted base file is looked for, which is the deprecated pre-protocol-8 pipeline. Accepts either a file or a directory to search; overlays are read from a `custom/` directory beside it either way, so 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. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Directory holding the custom/ overlays (and, with no shard link, a converted base file). Blank clears it." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */
|
||
adminOnly,
|
||
body('path').isString().isLength({ max: 512 }),
|
||
validate,
|
||
shardClilocs.setPath,
|
||
)
|
||
|
||
// ── Client assets (admin only) ────────────────────────────────────────────
|
||
// Creature artwork, read from the shard's own UO client over the bridge
|
||
// (docs/link/v8.md §6, §8). Sits beside the cliloc routes for the same reason
|
||
// they sit beside the atlas: static content derived from the operator's own
|
||
// files, and operating it is shard administration.
|
||
//
|
||
// There is deliberately NO public counterpart. The pictures are served as
|
||
// ordinary files under `/uploads`, and `shard_spawn_creatures.art` names them on
|
||
// the atlas responses the site already returns — so nothing public needs to know
|
||
// this pipeline exists.
|
||
shardRouter.get(
|
||
'/assets',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Client asset import status: what is loaded, what the shard has, whether they differ (admin only)'
|
||
// #swagger.description = 'What the site currently holds (the imported body catalogue, how many sprites are stored, how many atlas creatures resolved to a body id) beside what the shard reports for the client files those pictures come from. `drift: true` means the client files have changed since the last import — press Import. `shard.hashing: true` means a null hash is “not computed yet”, not “changed”: the shard hashes 195 MB anim files off the request path. `shard.imaging.ok: false` is the named NO_IMAGING state — a Linux shard host without libgdiplus cannot render a sprite at all, and the reason names the package to install. A shard with no link configured, or one that is down, is a reported state with a reason rather than an error.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Asset import status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAssetStatus" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
shardAssets.getStatus,
|
||
)
|
||
shardRouter.post(
|
||
'/assets/import',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Import creature artwork from the shard’s UO client (admin only)'
|
||
// #swagger.description = 'Walks the shard’s asset manifest, fetches only the sprites whose hash changed, stores them under uploads/atlas/, re-resolves every atlas creature to a body id and points each creature at its picture. This is the ONLY thing that imports — boot deliberately never calls the shard — so it is what an operator presses after patching their client. `force` re-imports even when the client files are unchanged. `approve` accepts a catalogue that no longer offers assets this site holds; refused by default, because an unmounted client volume and a deliberate downgrade are indistinguishable from the server and the wrong guess deletes artwork. An operator-supplied `spawnAtlas.art.json` always wins over an imported sprite. Nothing here throws for an operator-visible problem: a shard that is down, an asset plane switched off, or a host that cannot render images all answer 200 with status "unavailable" and a reason naming what to fix. Assets a client simply does not have are NOT failures — two thirds of the playable ghost and gargoyle bodies have no art on a stock client.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Import even if the shard’s client files are unchanged." }, approve: { type: "boolean", description: "Accept a catalogue that no longer offers assets this site holds." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAssetImportResult" } } } } */
|
||
adminOnly,
|
||
body('force').optional().isBoolean(),
|
||
body('approve').optional().isBoolean(),
|
||
validate,
|
||
shardAssets.importAssets,
|
||
)
|
||
|
||
shardRouter.post(
|
||
'/assets/warm',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Fetch item and land artwork the site is missing, now (admin only)'
|
||
// #swagger.description = 'Runs one pass of the item-art warm loop instead of waiting for its timer. The pass works out which item pictures this site's own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass — and fetches the ones it does not already hold from the shard, hued and stored under uploads/items/. There is deliberately NO manifest and no bulk import here: the client addresses 49,152 item graphics times three thousand hues, so the working set is defined by what the site actually displays. `force` re-fetches pictures the site already holds, which is how an operator recovers a wiped uploads volume. `limit` bounds one pass; the default is 400, because the shard serves one asset request at a time and a pass must not hold that slot against an import. Nothing throws for an operator-visible problem: no shard configured, a shard that is down, an asset plane switched off, a host with no libgdiplus, or a plugin overlay too old to serve item art all answer 200 with status "unavailable"/"skipped" and a reason naming what to fix.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Re-fetch pictures this site already holds." }, limit: { type: "integer", description: "How many keys this pass may fetch (1-2000)." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'What the pass did', content: { "application/json": { schema: { $ref: "#/components/schemas/UoItemArtWarmResult" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('force').optional().isBoolean(),
|
||
body('limit').optional().isInt({ min: 1, max: 2000 }),
|
||
validate,
|
||
shardAssets.warmItemArt,
|
||
)
|
||
|
||
// ── Feature visibility (admin only) ───────────────────────────────────
|
||
// Who can see which shard surface, and which sensitive fields within it. This
|
||
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||
shardRouter.get(
|
||
'/visibility',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
||
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
|
||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
shardVisibility.getVisibility,
|
||
)
|
||
shardRouter.put(
|
||
'/visibility',
|
||
// #swagger.tags = ['Admin · Shard']
|
||
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
||
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityUpdate" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
|
||
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||
adminOnly,
|
||
body('features').isObject(),
|
||
validate,
|
||
shardVisibility.putVisibility,
|
||
)
|
||
|
||
module.exports = shardRouter
|