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>
388 lines
26 KiB
JavaScript
388 lines
26 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 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/ShardLinkRequest" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||
/* #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/ShardLink" } } } } } */
|
||
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/ShardVendorSale" } } } } } */
|
||
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/ShardEvent" } } } } } */
|
||
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/ShardHouse" } } } } } */
|
||
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/AtlasStatus" } } } } */
|
||
/* #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/AtlasRefreshResult" } } } } */
|
||
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/AtlasRefreshResult" } } } } */
|
||
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/AtlasRefreshResult" } } } } */
|
||
/* #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/AtlasStatus" } } } } */
|
||
adminOnly,
|
||
body('path').isString().isLength({ max: 512 }),
|
||
validate,
|
||
shardAtlas.setPath,
|
||
)
|
||
|
||
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||
// UO's id → display-string map, converted once by the operator from their own
|
||
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||
// it is static content derived from operator-supplied files rather than anything
|
||
// the sidecar sends, and operating it is shard administration.
|
||
//
|
||
// 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 the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||
/* #swagger.responses[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 files (admin only)'
|
||
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||
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 source the site reads from (admin only)'
|
||
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||
adminOnly,
|
||
body('path').isString().isLength({ max: 512 }),
|
||
validate,
|
||
shardClilocs.setPath,
|
||
)
|
||
|
||
// ── 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/ShardVisibilityConfig" } } } } */
|
||
/* #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/ShardVisibilityUpdate" } } } } */
|
||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||
/* #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
|