refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers (PR 4) #105
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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,
|
|
||||||
)
|
|
||||||
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 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,
|
|
||||||
)
|
|
||||||
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 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.
|
|
||||||
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
|
|
||||||
@@ -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')
|
||||||
|
|||||||
58
server/src/router/v1/admin/dashboard.router.js
Normal file
58
server/src/router/v1/admin/dashboard.router.js
Normal 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
|
||||||
51
server/src/router/v1/admin/discordBot.router.js
Normal file
51
server/src/router/v1/admin/discordBot.router.js
Normal 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
|
||||||
96
server/src/router/v1/admin/email.router.js
Normal file
96
server/src/router/v1/admin/email.router.js
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
45
server/src/router/v1/admin/settings.router.js
Normal file
45
server/src/router/v1/admin/settings.router.js
Normal 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
|
||||||
235
server/src/router/v1/admin/shard.router.js
Normal file
235
server/src/router/v1/admin/shard.router.js
Normal 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 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
|
||||||
99
server/src/router/v1/admin/uoLink.router.js
Normal file
99
server/src/router/v1/admin/uoLink.router.js
Normal 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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user