PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2),
and the last admin one: it moves the entire residual 33 and DELETES
admin.routes.js. Every one of the 110 admin routes is now declared in a
capability router. No URL, gate or handler changes.
shard.router.js (16) /admin/shard
uoLink.router.js ( 5) /admin/uo-link
email.router.js ( 6) /admin/email
discordBot.router.js ( 2) /admin/discord-bot
settings.router.js ( 2) /admin/settings
dashboard.router.js ( 2) GET /dashboard + PUT /site-mode, at the group root
admin.routes.js deleted, was 33
No gate moved to router level. Every adminOnly in the residual file was
per-route, and modAccess on /shard must stay per-route because half that router
must not have it — which keeps the per-route handler count intact, the one
number routes.guards.json can actually check.
/shard is the first prefix where two tiers share one router: 7 self-service
account-linking routes (no extra gate, served by the same player/shard
controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on
modAccess. Prefix ownership beats tag grouping — splitting by tag would put two
routers under one prefix for no gain. The tag mismatch stays; retagging is a
real spec diff and belongs in a PR about tags.
dashboard.router.js is the one router mounted at the group root rather than a
prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe
only because the file declares no router-level middleware — a bare use(gate) in
a root-mounted router would run for every request passing through toward
another mount. The file carries a comment saying so.
Acceptance — all four gates zero-diff:
routes.manifest.json unchanged (200 public + 2 internal)
routes.guards.json unchanged (no route lost or gained a gate)
swagger-output.json unchanged (198 operations)
api-route-inventory.json already in sync
plus 434 server tests green.
Verified separately, because no gate can catch it: introspecting the built
stack, all 59 literal admin paths still dispatch to their own layer — nothing
is captured first by a /:param sibling. The manifest sorts its entries, so
declaration order is invisible to it.
Also repoints the comments that referenced admin.routes.js by name
(botActivity/moderation controllers, the town-crier cap mirror in
announceJobs.logic.js) and generalizes the "the path is on the line after
router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which
was never about that one file.
Co-Authored-By: Claude <noreply@anthropic.com>
236 lines
14 KiB
JavaScript
236 lines
14 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 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')
|
||
|
||
// ── 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,
|
||
)
|
||
|
||
module.exports = shardRouter
|