// 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