Merge pull request 'refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers (PR 4)' (#105) from refactor/admin-router-split-4 into main
All checks were successful
sync-project-tree / sync (push) Successful in -11s
Build container images / build (push) Successful in 1m21s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m47s

Reviewed-on: #105
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-28 01:33:45 +00:00
15 changed files with 617 additions and 486 deletions

View File

@@ -41,7 +41,7 @@ jobs:
- name: Run server tests - name: Run server tests
run: npm test --prefix server run: npm test --prefix server
- name: Check the route manifest is current - name: Check the route manifest is current
# The URL surface is frozen while admin.routes.js is carved up by capability # The URL surface is frozen while the routers are carved up by capability
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express # (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that # stack and diffing proves a "mechanical" refactor moved no URL. A PR that
# really does change one has to commit the new manifest, putting it in front # really does change one has to commit the new manifest, putting it in front

View File

@@ -389,8 +389,8 @@ npm run routes:manifest # → routes.manifest.json + routes.guards.j
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs) npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
``` ```
The generator walks the live Express stack (runtime introspection, not source parsing — route paths in The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
`admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats greps) and keeps only sits on the line *after* `router.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand` `/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built. not depend on whether the client has been built.

View File

@@ -11,8 +11,8 @@
* in front of a reviewer instead of letting it slip through a "mechanical" PR. * in front of a reviewer instead of letting it slip through a "mechanical" PR.
* *
* Runtime introspection, not source parsing: it is authoritative about mounts, and * Runtime introspection, not source parsing: it is authoritative about mounts, and
* the route paths in admin.routes.js sit on the line *after* `adminRouter.get(`, * a route's path sits on the line *after* `router.get(`, which defeats naive
* which defeats naive greps. Not swagger-output.json either — that is annotation- * greps. Not swagger-output.json either — that is annotation-
* derived (only annotated routes appear) and documents intent; this records reality. * derived (only annotated routes appear) and documents intent; this records reality.
* *
* Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything * Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything

View File

@@ -11,7 +11,7 @@
const { deriveExcerpt } = require('../../utils/sanitizeHtml') const { deriveExcerpt } = require('../../utils/sanitizeHtml')
// Sidecar town-crier caps, mirrored from the admin route validation // Sidecar town-crier caps, mirrored from the admin route validation
// (admin.routes.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })). // (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
// We pre-truncate to these so a published post never bounces with towncrier.error. // We pre-truncate to these so a published post never bounces with towncrier.error.
const MAX_LINES = 8 const MAX_LINES = 8
const MAX_LINE_LEN = 200 const MAX_LINE_LEN = 200

View File

@@ -1,472 +0,0 @@
// Residual /admin routes — the capabilities not yet carved into their own
// router file (docs/website/API_V2_PLAN.md § Phase 2). Mounted at the root of
// /api/v1/admin by admin/index.js, *after* the extracted capability routers and
// behind the shared `noindex, isLoggedIn, staffOnly` gate it owns, so the URLs
// here are unchanged from when this file held all 110 admin routes.
//
// Already extracted: users, account, invites, auth/providers, moderation,
// bot-activity, activity, posts, uploads, wiki, pages.
// Still here: shard, dashboard, site-mode, settings, discord-bot, email,
// uo-link.
// This file disappears when the last group moves.
const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./admin.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
const shardOps = require('./shardOps.controller')
const selfShard = require('../player/shard.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
const adminRouter = express.Router()
// Admin-only gate. Editors may manage content (posts/wiki), but user
// management, site mode, and settings are restricted to the admin role.
const adminOnly = requireRole('admin')
// Moderator gate. Admins can do everything a moderator can. Since the moderation
// dashboard moved to moderation.router.js this guards only the in-game staff
// operations below (/shard/*), which stay here until PR 4.
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}$/
adminRouter.post(
'/shard/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,
)
adminRouter.get(
'/shard/accounts',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'List the callers 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,
)
adminRouter.get(
'/shard/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,
)
adminRouter.get(
'/shard/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,
)
adminRouter.get(
'/shard/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,
)
adminRouter.get(
'/shard/sales',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Recent player-vendor sales for the callers 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,
)
adminRouter.post(
'/shard/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 shards 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.
adminRouter.post(
'/shard/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,
)
adminRouter.post(
'/shard/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,
)
adminRouter.post(
'/shard/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,
)
adminRouter.post(
'/shard/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,
)
adminRouter.get(
'/shard/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,
)
adminRouter.post(
'/shard/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,
)
adminRouter.post(
'/shard/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,
)
adminRouter.get(
'/shard/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,
)
adminRouter.get(
'/shard/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,
)
// ── Dashboard & site mode ─────────────────────────────────────────────
adminRouter.get(
'/dashboard',
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Dashboard summary counts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.dashboard,
)
adminRouter.put(
'/site-mode',
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Set site mode (admin only)'
// #swagger.description = 'Switch the site between live and maintenance.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('mode').isIn(['live', 'maintenance']),
validate,
ctrl.setSiteMode,
)
// ── Settings ──────────────────────────────────────────────────────────
adminRouter.get(
'/settings',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Get all site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'All settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.getSettings,
)
adminRouter.put(
'/settings',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Update site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.updateSettings,
)
// ── Discord bot control (admin only) ──────────────────────────────────
// Phase 1: entering/enabling the bot token here — never an env var. The token
// is write-only over this API (SECURITY note in discordBot.controller.js).
adminRouter.get(
'/discord-bot/config',
// #swagger.tags = ['Admin · Discord Bot']
// #swagger.summary = 'Get Discord bot config + live status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Masked config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
discordBot.getConfig,
)
adminRouter.put(
'/discord-bot/config',
// #swagger.tags = ['Admin · Discord Bot']
// #swagger.summary = 'Save Discord bot config (admin only)'
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one unchanged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { guildId: { type: "string" }, token: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error, invalid token, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('guildId').optional({ values: 'falsy' }).isString().trim(),
body('token').optional({ values: 'falsy' }).isString().trim(),
body('enabled').optional().isBoolean(),
validate,
discordBot.saveConfig,
)
// ── Email delivery (Gmail OAuth2, admin only) ─────────────────────────
// Modern replacement for env SMTP: the refresh token is captured by the connect
// flow and is write-only over this API (stored encrypted, never returned).
adminRouter.get(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Get email delivery config + status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.getConfig,
)
adminRouter.put(
'/email/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Update email delivery config (admin only)'
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
body('enabled').optional().isBoolean(),
validate,
emailConfig.saveConfig,
)
adminRouter.get(
'/email/connect/start',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.connectStart,
)
adminRouter.get(
'/email/connect/callback',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
adminOnly,
emailConfig.connectCallback,
)
adminRouter.post(
'/email/test',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Send a test email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate,
emailConfig.testSend,
)
adminRouter.post(
'/email/disconnect',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.disconnect,
)
// ── uo-link sidecar control (admin only) ──────────────────────────────────
// Connection config (base/ws URL + token + protocol + enabled) and the town
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
adminRouter.get(
'/uo-link/config',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
uoLink.getConfig,
)
adminRouter.put(
'/uo-link/config',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Save uo-link connection config (admin only)'
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
body('token').optional({ values: 'falsy' }).isString().trim(),
body('protocol').optional().isInt({ min: 1, max: 99 }),
body('enabled').optional().isBoolean(),
validate,
uoLink.saveConfig,
)
adminRouter.post(
'/uo-link/towncrier',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('id').isString().trim().isLength({ min: 1, max: 64 }),
body('lines').isArray({ min: 1, max: 8 }),
body('lines.*').isString().isLength({ max: 200 }),
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
validate,
uoLink.postTownCrier,
)
adminRouter.delete(
'/uo-link/towncrier/:id',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Remove a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isString().trim().isLength({ min: 1, max: 64 }),
validate,
uoLink.deleteTownCrier,
)
adminRouter.get(
'/uo-link/stream',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
adminOnly,
uoLink.stream,
)
module.exports = adminRouter

View File

@@ -1,7 +1,7 @@
// Bot-scoring / IP-ban visibility for admins. Read-only view of the botScore // Bot-scoring / IP-ban visibility for admins. Read-only view of the botScore
// middleware's in-memory state plus a recent-events feed, and a single mutating // middleware's in-memory state plus a recent-events feed, and a single mutating
// action — an emergency unban for false positives. Mounted behind the admin-only // action — an emergency unban for false positives. Mounted behind the admin-only
// RBAC gate (see admin.routes.js). This is visibility + emergency unban only; // RBAC gate (see botActivity.router.js). This is visibility + emergency unban only;
// there is deliberately no way to add a ban or change scoring weights from here. // there is deliberately no way to add a ban or change scoring weights from here.
const botScore = require('../../../middleware/botScore') const botScore = require('../../../middleware/botScore')

View File

@@ -0,0 +1,58 @@
// Admin · Dashboard — the landing summary, plus the /site-mode singleton.
//
// Mounted at the ROOT of /api/v1/admin by admin/index.js (not at a prefix),
// which already applied `noindex, isLoggedIn, staffOnly`. Two singleton URLs
// that share a swagger tag and a screen but not a path segment live together
// here rather than in two one-route files, which is what the target tree in
// docs/website/API_V2_PLAN.md § Phase 2 calls for.
//
// A root mount is the one place the split's "always mount at a prefix" rule is
// relaxed, and it is safe ONLY because this file declares no router-level
// middleware: a bare `use(gate)` here would run for every request passing
// through toward another mount and 403 an editor on an unrelated route. Keep
// gates per-route in this file.
//
// GET /dashboard — stats overview, any staff role.
// PUT /site-mode — live ↔ maintenance, admin only.
//
// Neither is the audit log (/activity) nor the bot-scoring state
// (/bot-activity); those are separate capabilities that read alike. Handlers
// still live in admin.controller.js; this re-wires routes, not logic.
const express = require('express')
const { body } = require('express-validator')
const ctrl = require('./admin.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
const dashboardRouter = express.Router()
const adminOnly = requireRole('admin')
dashboardRouter.get(
'/dashboard',
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Dashboard summary counts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Summary: site mode, last change, post/user counts and recent activity', content: { "application/json": { schema: { type: "object", properties: { site_mode: { type: "string", example: "live" }, last_change: { type: "object", properties: { at: { type: "string", nullable: true }, by: { type: "string", nullable: true } } }, counts: { type: "object", properties: { posts: { type: "object", additionalProperties: true }, users: { type: "integer" } } }, recent_activity: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.dashboard,
)
dashboardRouter.put(
'/site-mode',
// #swagger.tags = ['Admin · Dashboard']
// #swagger.summary = 'Set site mode (admin only)'
// #swagger.description = 'Switch the site between live and maintenance.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated site mode', content: { "application/json": { schema: { $ref: "#/components/schemas/SiteModeState" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('mode').isIn(['live', 'maintenance']),
validate,
ctrl.setSiteMode,
)
module.exports = dashboardRouter

View File

@@ -0,0 +1,51 @@
// Admin · Discord Bot — control plane for the bot process.
//
// Mounted at /api/v1/admin/discord-bot by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. The bot token is entered and enabled here,
// never through an env var, and is write-only over this API (SECURITY note in
// discordBot.controller.js).
//
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
// the middleware chain each route carries is unchanged by the move.
const express = require('express')
const { body } = require('express-validator')
const discordBot = require('./discordBot.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
const discordBotRouter = express.Router()
const adminOnly = requireRole('admin')
discordBotRouter.get(
'/config',
// #swagger.tags = ['Admin · Discord Bot']
// #swagger.summary = 'Get Discord bot config + live status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Masked config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
discordBot.getConfig,
)
discordBotRouter.put(
'/config',
// #swagger.tags = ['Admin · Discord Bot']
// #swagger.summary = 'Save Discord bot config (admin only)'
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one unchanged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { guildId: { type: "string" }, token: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error, invalid token, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('guildId').optional({ values: 'falsy' }).isString().trim(),
body('token').optional({ values: 'falsy' }).isString().trim(),
body('enabled').optional().isBoolean(),
validate,
discordBot.saveConfig,
)
module.exports = discordBotRouter

View File

@@ -0,0 +1,96 @@
// Admin · Email — outbound mail delivery via Gmail OAuth2.
//
// Mounted at /api/v1/admin/email by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. The modern replacement for env SMTP: the
// refresh token is captured by the connect flow below and is write-only over
// this API (stored encrypted by utils/secretBox.js, never returned).
//
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
// the middleware chain each route carries is unchanged by the move.
const express = require('express')
const { body } = require('express-validator')
const emailConfig = require('./emailConfig.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
const emailRouter = express.Router()
const adminOnly = requireRole('admin')
emailRouter.get(
'/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Get email delivery config + status (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Config (refresh token stripped) + status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.getConfig,
)
emailRouter.put(
'/config',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Update email delivery config (admin only)'
// #swagger.description = 'Set the From display name and enabled toggle. Enabling requires a connected Gmail account.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { senderName: { type: "string" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Cannot enable before connecting a mailbox', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('senderName').optional({ values: 'null' }).isString().trim().isLength({ max: 120 }),
body('enabled').optional().isBoolean(),
validate,
emailConfig.saveConfig,
)
emailRouter.get(
'/connect/start',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Begin the Gmail OAuth2 connect flow (admin only)'
// #swagger.description = 'Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Authorization URL', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Google OAuth client not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.connectStart,
)
emailRouter.get(
'/connect/callback',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'OAuth2 callback — stores the refresh token, redirects to Settings'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[302] = { description: 'Redirect back to /admin/settings' } */
adminOnly,
emailConfig.connectCallback,
)
emailRouter.post(
'/test',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Send a test email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", format: "email" } } } } } } */
/* #swagger.responses[200] = { description: 'Sent', content: { "application/json": { schema: { type: "object", properties: { sent: { type: "boolean" }, to: { type: "string" } } } } } } */
/* #swagger.responses[502] = { description: 'Send failed / not configured', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('to').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
validate,
emailConfig.testSend,
)
emailRouter.post(
'/disconnect',
// #swagger.tags = ['Admin · Email']
// #swagger.summary = 'Disconnect Gmail and disable email (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Disconnected config', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
emailConfig.disconnect,
)
module.exports = emailRouter

View File

@@ -6,7 +6,10 @@
// emitted URL set is byte-identical — proved per PR by a zero-line diff in // emitted URL set is byte-identical — proved per PR by a zero-line diff in
// server/routes.manifest.json (`npm run routes:manifest`). // server/routes.manifest.json (`npm run routes:manifest`).
// //
// See docs/website/API_V2_PLAN.md § Phase 2 for the split and its remaining PRs. // The admin group is fully split as of PR 4: admin.routes.js is gone and every
// one of the 110 admin routes is declared in a capability router below.
//
// See docs/website/API_V2_PLAN.md § Phase 2 for the split.
const express = require('express') const express = require('express')
@@ -24,7 +27,12 @@ const postsRouter = require('./posts.router')
const uploadsRouter = require('./uploads.router') const uploadsRouter = require('./uploads.router')
const wikiRouter = require('./wiki.router') const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router') const pagesRouter = require('./pages.router')
const residualRouter = require('./admin.routes') const shardRouter = require('./shard.router')
const uoLinkRouter = require('./uoLink.router')
const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router() const adminRouter = express.Router()
@@ -56,9 +64,20 @@ adminRouter.use('/posts', postsRouter)
adminRouter.use('/uploads', uploadsRouter) adminRouter.use('/uploads', uploadsRouter)
adminRouter.use('/wiki', wikiRouter) adminRouter.use('/wiki', wikiRouter)
adminRouter.use('/pages', pagesRouter) adminRouter.use('/pages', pagesRouter)
// Ops and configuration. /shard mixes tiers on one prefix — self-service game
// account linking (no extra gate) alongside modAccess in-game staff ops — so
// one router owns the prefix and gates per route. The rest are admin-only.
// /admin/shard/pages is the in-game help-page queue, unrelated to /admin/pages.
adminRouter.use('/shard', shardRouter)
adminRouter.use('/uo-link', uoLinkRouter)
adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter)
// Everything not yet extracted, at the group root. Mounted last, but none of the // The two singletons that own no path segment of their own: GET /dashboard and
// prefixes above appear in it, so nothing here depends on the ordering. // PUT /site-mode. Mounted at the group root, last, exactly where the residual
adminRouter.use('/', residualRouter) // admin.routes.js used to sit — safe because dashboard.router.js declares no
// router-level middleware, only its two routes.
adminRouter.use('/', dashboardRouter)
module.exports = adminRouter module.exports = adminRouter

View File

@@ -1,6 +1,6 @@
// Admin moderation dashboard (Phase 6). Read-only views over the bot's // Admin moderation dashboard (Phase 6). Read-only views over the bot's
// mod_actions log plus server-owned staff notes. Mounted behind the // mod_actions log plus server-owned staff notes. Mounted behind the
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is // admin+moderator RBAC gate (see moderation.router.js). The only mutation here is
// adding a staff note; admin_only notes are further restricted to the admin role. // adding a staff note; admin_only notes are further restricted to the admin role.
const moderation = require('../../../model/moderation/moderation.model') const moderation = require('../../../model/moderation/moderation.model')
const modNotes = require('../../../model/modNotes/modNotes.model') const modNotes = require('../../../model/modNotes/modNotes.model')

View File

@@ -0,0 +1,45 @@
// Admin · Settings — the site-wide key/value settings store.
//
// Mounted at /api/v1/admin/settings by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Editors may manage content, but settings
// are admin-only: this store gates registration, game-account signup, the
// contact form and the rest of the site's behaviour switches.
//
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
// the middleware chain each route carries is unchanged by the move. Handlers
// still live in admin.controller.js; this re-wires routes, not logic.
const express = require('express')
const ctrl = require('./admin.controller')
const { requireRole } = require('../../../utils/auth')
const settingsRouter = express.Router()
const adminOnly = requireRole('admin')
settingsRouter.get(
'/',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Get all site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'All settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.getSettings,
)
settingsRouter.put(
'/',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Update site settings (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.updateSettings,
)
module.exports = settingsRouter

View File

@@ -0,0 +1,235 @@
// 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 callers 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 callers 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 shards 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

View File

@@ -0,0 +1,99 @@
// Admin · uo-link — the sidecar connection config, the town crier, and the
// staff SSE stream.
//
// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. This is where shard integration is
// configured: base/ws URL, bearer token, protocol version and the enabled
// toggle all live in the DB (uoLinkConfig), never in env. The token is
// write-only over this API (SECURITY note in uoLink.controller.js).
//
// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection
// and login attempts on top of the public event kinds. The public/admin
// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly
// gate below is its other half.
//
// The routes keep their `Admin · Shard` swagger tag: retagging is a real
// OpenAPI diff and does not belong in a route-move PR.
//
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
// the middleware chain each route carries is unchanged by the move.
const express = require('express')
const { body, param } = require('express-validator')
const uoLink = require('./uoLink.controller')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
const uoLinkRouter = express.Router()
const adminOnly = requireRole('admin')
uoLinkRouter.get(
'/config',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
uoLink.getConfig,
)
uoLinkRouter.put(
'/config',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Save uo-link connection config (admin only)'
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
body('token').optional({ values: 'falsy' }).isString().trim(),
body('protocol').optional().isInt({ min: 1, max: 99 }),
body('enabled').optional().isBoolean(),
validate,
uoLink.saveConfig,
)
uoLinkRouter.post(
'/towncrier',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('id').isString().trim().isLength({ min: 1, max: 64 }),
body('lines').isArray({ min: 1, max: 8 }),
body('lines.*').isString().isLength({ max: 200 }),
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
validate,
uoLink.postTownCrier,
)
uoLinkRouter.delete(
'/towncrier/:id',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Remove a town-crier message (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isString().trim().isLength({ min: 1, max: 64 }),
validate,
uoLink.deleteTownCrier,
)
uoLinkRouter.get(
'/stream',
// #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
adminOnly,
uoLink.stream,
)
module.exports = uoLinkRouter

View File

@@ -2,7 +2,7 @@
// //
// The auth logic now lives in server/src/auth/ (token primitives, the session // The auth logic now lives in server/src/auth/ (token primitives, the session
// service, and session middleware). This module stays as a thin facade so every // service, and session middleware). This module stays as a thin facade so every
// existing import site (auth.routes, admin.routes, siteMode, auth.controller) // existing import site (auth.routes, the admin/* routers, siteMode, auth.controller)
// keeps working with the exact same names and behavior — nothing else in the // keeps working with the exact same names and behavior — nothing else in the
// codebase needs to change. New code should prefer requiring ../auth/* directly. // codebase needs to change. New code should prefer requiring ../auth/* directly.