From 8fd0d825808f070324f8f5eeef0fa6fd3a62b790 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 20:02:28 -0500 Subject: [PATCH] refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2), and the last admin one: it moves the entire residual 33 and DELETES admin.routes.js. Every one of the 110 admin routes is now declared in a capability router. No URL, gate or handler changes. shard.router.js (16) /admin/shard uoLink.router.js ( 5) /admin/uo-link email.router.js ( 6) /admin/email discordBot.router.js ( 2) /admin/discord-bot settings.router.js ( 2) /admin/settings dashboard.router.js ( 2) GET /dashboard + PUT /site-mode, at the group root admin.routes.js deleted, was 33 No gate moved to router level. Every adminOnly in the residual file was per-route, and modAccess on /shard must stay per-route because half that router must not have it — which keeps the per-route handler count intact, the one number routes.guards.json can actually check. /shard is the first prefix where two tiers share one router: 7 self-service account-linking routes (no extra gate, served by the same player/shard controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on modAccess. Prefix ownership beats tag grouping — splitting by tag would put two routers under one prefix for no gain. The tag mismatch stays; retagging is a real spec diff and belongs in a PR about tags. dashboard.router.js is the one router mounted at the group root rather than a prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe only because the file declares no router-level middleware — a bare use(gate) in a root-mounted router would run for every request passing through toward another mount. The file carries a comment saying so. Acceptance — all four gates zero-diff: routes.manifest.json unchanged (200 public + 2 internal) routes.guards.json unchanged (no route lost or gained a gate) swagger-output.json unchanged (198 operations) api-route-inventory.json already in sync plus 434 server tests green. Verified separately, because no gate can catch it: introspecting the built stack, all 59 literal admin paths still dispatch to their own layer — nothing is captured first by a /:param sibling. The manifest sorts its entries, so declaration order is invisible to it. Also repoints the comments that referenced admin.routes.js by name (botActivity/moderation controllers, the town-crier cap mirror in announceJobs.logic.js) and generalizes the "the path is on the line after router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which was never about that one file. Co-Authored-By: Claude --- .gitea/workflows/pr-checks.yml | 2 +- README.md | 4 +- server/scripts/routeManifest.js | 4 +- .../model/announceJobs/announceJobs.logic.js | 2 +- server/src/router/v1/admin/admin.routes.js | 472 ------------------ .../router/v1/admin/botActivity.controller.js | 2 +- .../src/router/v1/admin/dashboard.router.js | 58 +++ .../src/router/v1/admin/discordBot.router.js | 51 ++ server/src/router/v1/admin/email.router.js | 96 ++++ server/src/router/v1/admin/index.js | 29 +- .../router/v1/admin/moderation.controller.js | 2 +- server/src/router/v1/admin/settings.router.js | 45 ++ server/src/router/v1/admin/shard.router.js | 235 +++++++++ server/src/router/v1/admin/uoLink.router.js | 99 ++++ server/src/utils/auth.js | 2 +- 15 files changed, 617 insertions(+), 486 deletions(-) delete mode 100644 server/src/router/v1/admin/admin.routes.js create mode 100644 server/src/router/v1/admin/dashboard.router.js create mode 100644 server/src/router/v1/admin/discordBot.router.js create mode 100644 server/src/router/v1/admin/email.router.js create mode 100644 server/src/router/v1/admin/settings.router.js create mode 100644 server/src/router/v1/admin/shard.router.js create mode 100644 server/src/router/v1/admin/uoLink.router.js diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index b80b24f..47baf13 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -41,7 +41,7 @@ jobs: - name: Run server tests run: npm test --prefix server - 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 # 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 diff --git a/README.md b/README.md index 1f378c4..be7343b 100644 --- a/README.md +++ b/README.md @@ -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) ``` -The generator walks the live Express stack (runtime introspection, not source parsing — route paths in -`admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats greps) and keeps only +The generator walks the live Express stack (runtime introspection, not source parsing — a route's path +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` 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. diff --git a/server/scripts/routeManifest.js b/server/scripts/routeManifest.js index e0f362b..19522cb 100644 --- a/server/scripts/routeManifest.js +++ b/server/scripts/routeManifest.js @@ -11,8 +11,8 @@ * 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 - * the route paths in admin.routes.js sit on the line *after* `adminRouter.get(`, - * which defeats naive greps. Not swagger-output.json either — that is annotation- + * a route's path sits on the line *after* `router.get(`, which defeats naive + * greps. Not swagger-output.json either — that is annotation- * derived (only annotated routes appear) and documents intent; this records reality. * * Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything diff --git a/server/src/model/announceJobs/announceJobs.logic.js b/server/src/model/announceJobs/announceJobs.logic.js index f959845..b1d0f64 100644 --- a/server/src/model/announceJobs/announceJobs.logic.js +++ b/server/src/model/announceJobs/announceJobs.logic.js @@ -11,7 +11,7 @@ const { deriveExcerpt } = require('../../utils/sanitizeHtml') // 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. const MAX_LINES = 8 const MAX_LINE_LEN = 200 diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js deleted file mode 100644 index 8f1207a..0000000 --- a/server/src/router/v1/admin/admin.routes.js +++ /dev/null @@ -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 diff --git a/server/src/router/v1/admin/botActivity.controller.js b/server/src/router/v1/admin/botActivity.controller.js index 39a593e..cf561d1 100644 --- a/server/src/router/v1/admin/botActivity.controller.js +++ b/server/src/router/v1/admin/botActivity.controller.js @@ -1,7 +1,7 @@ // 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 // 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. const botScore = require('../../../middleware/botScore') diff --git a/server/src/router/v1/admin/dashboard.router.js b/server/src/router/v1/admin/dashboard.router.js new file mode 100644 index 0000000..2927d7d --- /dev/null +++ b/server/src/router/v1/admin/dashboard.router.js @@ -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 diff --git a/server/src/router/v1/admin/discordBot.router.js b/server/src/router/v1/admin/discordBot.router.js new file mode 100644 index 0000000..0b5ae9f --- /dev/null +++ b/server/src/router/v1/admin/discordBot.router.js @@ -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 diff --git a/server/src/router/v1/admin/email.router.js b/server/src/router/v1/admin/email.router.js new file mode 100644 index 0000000..ff2c38c --- /dev/null +++ b/server/src/router/v1/admin/email.router.js @@ -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 diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index a72b182..dd4c3cc 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -6,7 +6,10 @@ // emitted URL set is byte-identical — proved per PR by a zero-line diff in // 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') @@ -24,7 +27,12 @@ const postsRouter = require('./posts.router') const uploadsRouter = require('./uploads.router') const wikiRouter = require('./wiki.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() @@ -56,9 +64,20 @@ adminRouter.use('/posts', postsRouter) adminRouter.use('/uploads', uploadsRouter) adminRouter.use('/wiki', wikiRouter) 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 -// prefixes above appear in it, so nothing here depends on the ordering. -adminRouter.use('/', residualRouter) +// The two singletons that own no path segment of their own: GET /dashboard and +// PUT /site-mode. Mounted at the group root, last, exactly where the residual +// 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 diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js index 38558bf..b4f1f5d 100644 --- a/server/src/router/v1/admin/moderation.controller.js +++ b/server/src/router/v1/admin/moderation.controller.js @@ -1,6 +1,6 @@ // Admin moderation dashboard (Phase 6). Read-only views over the bot's // 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. const moderation = require('../../../model/moderation/moderation.model') const modNotes = require('../../../model/modNotes/modNotes.model') diff --git a/server/src/router/v1/admin/settings.router.js b/server/src/router/v1/admin/settings.router.js new file mode 100644 index 0000000..626cce1 --- /dev/null +++ b/server/src/router/v1/admin/settings.router.js @@ -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 diff --git a/server/src/router/v1/admin/shard.router.js b/server/src/router/v1/admin/shard.router.js new file mode 100644 index 0000000..70a2ada --- /dev/null +++ b/server/src/router/v1/admin/shard.router.js @@ -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 diff --git a/server/src/router/v1/admin/uoLink.router.js b/server/src/router/v1/admin/uoLink.router.js new file mode 100644 index 0000000..8a4fd90 --- /dev/null +++ b/server/src/router/v1/admin/uoLink.router.js @@ -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 diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js index 64d1761..e5f85a5 100644 --- a/server/src/utils/auth.js +++ b/server/src/utils/auth.js @@ -2,7 +2,7 @@ // // 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 -// 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 // codebase needs to change. New code should prefer requiring ../auth/* directly. -- 2.49.1