Shards edit items and add new ones, and those carry cliloc ids no stock client
table has. Reading exactly one converted file meant an operator had to
re-export 5 MB every time they added one item — friction enough that the table
would simply go stale, which is the failure the spawn atlas was redesigned to
avoid in the first place.
So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client
table) plus every operator-maintained overlay under `custom/`, all re-read on
every boot and hash-gated as a SET. Later sources win, so an overlay both adds
ids the client never had and overrides stock ones the shard re-purposed.
Adding, editing or removing any overlay counts as drift.
`custom/` is the one convention here that is ours rather than the shard's, and
deliberately so: ServUO has no server-side notion of a custom cliloc — they
live in the patched client a shard distributes, and nothing in the tree
declares them. There is nothing to discover. (An operator who does patch their
client cliloc needs no overlay: convert the patched file and the edits are in
the base.) Scale, measured on the live shard: its script tree references 16,434
cliloc ids and only 37 are absent from stock — tens against a 67k base, which
is why this is an overlay and not a second table.
The set brings back a hazard a single file did not have, and it gets the
atlas's answer. A corrupt source fails the parse loudly, but a source that has
VANISHED parses perfectly and imports a table quietly missing everything it
contributed — an unmounted volume is indistinguishable from a deliberate
deletion. So it is staged, not applied (`needsReview`), reported by both the
import and status(), and accepted with `{approve:true}`. That is a flag rather
than the atlas's approve/reject pair because the atlas stores a pending
decision SO THAT approving re-parses; here nothing is stored, so re-reading at
approval time is automatic.
Also reports a per-source breakdown (entries/added/overrode) on import and in
status, which is how an operator confirms an overlay took effect — "overrode: 0"
on a file meant to re-label stock items says it did not.
Two bugs this surfaced, both found by running a shard-style overlay rather than
by another stock-table fixture:
- displayText tidied punctuation unconditionally, so a custom
"Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
right after a placeholder is removed and wrong otherwise — the same condition
the `%` rule already had.
- CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename
CLILOCS.md and the export tool's README tell operators to write. Pointing at
the directory they were told to create failed with NO_FILE.
Verified end to end against the live MariaDB and a real server boot: base-only
import, overlay adding one id and overriding another (per-source breakdown
correct), unchanged set as a no-op, an edited overlay re-importing and
withdrawing its override, a vanished overlay refused with the table intact,
status reporting missingSources, approve applying it, and a file-path
configuration still finding overlays beside it. All three resolve correctly
through the running server: shard-added, overridden and stock. 646 server tests
pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger,
routes.manifest.json and routes.guards.json regenerated.
Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
26 KiB
JavaScript
387 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 express = require('express')
|
||
const { body, param } = require('express-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 } = require('../../../utils/auth')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
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
|