From 8ad892725f98f35421ac7651c472fe522dc05eab Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 15:54:00 -0500 Subject: [PATCH] refactor(server): split admin users, account, invites and auth providers into capability routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure mechanical re-wiring: routes move between files, no handler, gate, validator or annotation changes, and not one URL moves. New src/router/v1/admin/index.js owns the two things the group shares — the `noindex, isLoggedIn, staffOnly` gate and the mount table — and declares no routes itself. The gate sits ahead of every mount so a capability router extracted in a later PR cannot silently ship without it. Four capability routers mount at the prefix they already owned inside the monolith: account.router.js 6 routes -> /admin/account (self-service, no adminOnly) users.router.js 15 routes -> /admin/users (adminOnly, router-level) invites.router.js 3 routes -> /admin/invites (adminOnly, per-route) authProviders.router.js 4 routes -> /admin/auth (adminOnly, per-route) admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin routes) and is mounted last at the group root; none of the four prefixes appears in it, so nothing depends on mount ordering. It disappears when PR 5 lands. Handlers still live in admin.controller.js and usersShard.controller.js — this re-wires routes, not logic. `adminOnly` moves with the routes that use it, and `usersRouter.use(adminOnly)` is exactly equivalent to the old `adminRouter.use('/users', adminOnly)` now that the router is mounted at /users. All three generated gates are 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, byte-for-byte The spec staying byte-identical depends on the path normalization landed in the preceding commit; without it the four collection routes would have documented as /api/v1/admin/{users,invites,account}/ with a trailing slash. Server tests green (434/434). Co-Authored-By: Claude --- server/src/router/v1/admin/account.router.js | 87 ++++ server/src/router/v1/admin/admin.routes.js | 446 +----------------- .../router/v1/admin/authProviders.router.js | 102 ++++ server/src/router/v1/admin/index.js | 46 ++ server/src/router/v1/admin/invites.router.js | 54 +++ server/src/router/v1/admin/users.router.js | 249 ++++++++++ server/src/router/v1/v1.router.js | 2 +- 7 files changed, 551 insertions(+), 435 deletions(-) create mode 100644 server/src/router/v1/admin/account.router.js create mode 100644 server/src/router/v1/admin/authProviders.router.js create mode 100644 server/src/router/v1/admin/index.js create mode 100644 server/src/router/v1/admin/invites.router.js create mode 100644 server/src/router/v1/admin/users.router.js diff --git a/server/src/router/v1/admin/account.router.js b/server/src/router/v1/admin/account.router.js new file mode 100644 index 0000000..c674d2f --- /dev/null +++ b/server/src/router/v1/admin/account.router.js @@ -0,0 +1,87 @@ +// Admin · Account — self-service account security for staff. +// +// Mounted at /api/v1/admin/account by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Deliberately NOT behind adminOnly: an editor +// or moderator manages their own 2FA and linked identities here, exactly as a +// player does under /player. Every handler keys off req.user.id. + +const express = require('express') +const { body, param } = require('express-validator') + +const account = require('./account.controller') +const validate = require('../../../middleware/validate') + +const accountRouter = express.Router() + +accountRouter.get( + '/', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Get the current account (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + account.getAccount, +) +accountRouter.post( + '/totp/setup', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + account.totpSetup, +) +accountRouter.post( + '/totp/enable', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Enable 2FA by confirming a code' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ + /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ + /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('code').isString().trim().isLength({ min: 6, max: 8 }), + validate, + account.totpEnable, +) +accountRouter.post( + '/totp/disable', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Disable 2FA by confirming a code' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ + /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ + /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('code').isString().trim().isLength({ min: 6, max: 8 }), + validate, + account.totpDisable, +) + +// Linked SSO identities (self-service — any logged-in role manages their own). +accountRouter.get( + '/identities', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'List linked SSO identities (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + account.listIdentities, +) +accountRouter.delete( + '/identities/:provider', + // #swagger.tags = ['Admin · Account'] + // #swagger.summary = 'Unlink an SSO identity (self)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('provider').matches(/^[a-z0-9-]+$/), + validate, + account.unlinkIdentity, +) + +module.exports = accountRouter diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 161fe95..c954820 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -1,3 +1,14 @@ +// 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. +// Still here: shard, dashboard, site-mode, posts, uploads, wiki, pages, +// settings, activity, bot-activity, discord-bot, email, moderation, uo-link. +// This file disappears when the last group moves. + const express = require('express') const path = require('path') const fs = require('fs') @@ -6,32 +17,19 @@ const multer = require('multer') const { body, param } = require('express-validator') const ctrl = require('./admin.controller') -const account = require('./account.controller') const botActivity = require('./botActivity.controller') -const authProviders = require('./authProviders.controller') const discordBot = require('./discordBot.controller') const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') const shardOps = require('./shardOps.controller') -const usersShard = require('./usersShard.controller') -const invites = require('./invites.controller') const selfShard = require('../player/shard.controller') const moderation = require('./moderation.controller') const pagesCtrl = require('./pages.controller') -const { isLoggedIn, requireRole } = require('../../../utils/auth') -const noindex = require('../../../middleware/noindex') +const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') const adminRouter = express.Router() -// Every admin route requires auth, a STAFF role, and is kept out of search -// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted -// role: without it, the editor-tier routes below (dashboard, posts, wiki, -// uploads) that are only guarded by isLoggedIn would be reachable by players. -// Players get 403 here and use the self-scoped /player group instead. -const staffOnly = requireRole('admin', 'editor', 'moderator') -adminRouter.use(noindex, isLoggedIn, staffOnly) - // 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') @@ -41,79 +39,6 @@ const adminOnly = requireRole('admin') // admin check inside the controller. const modAccess = requireRole('admin', 'moderator') -// ── Account security (self-service, any logged-in role) ─────────────── -// Not behind adminOnly: an editor manages their own 2FA too. -adminRouter.get( - '/account', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Get the current account (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.getAccount, -) -adminRouter.post( - '/account/totp/setup', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.totpSetup, -) -adminRouter.post( - '/account/totp/enable', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Enable 2FA by confirming a code' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpEnable, -) -adminRouter.post( - '/account/totp/disable', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Disable 2FA by confirming a code' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */ - /* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */ - /* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('code').isString().trim().isLength({ min: 6, max: 8 }), - validate, - account.totpDisable, -) - -// Linked SSO identities (self-service — any logged-in role manages their own). -adminRouter.get( - '/account/identities', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'List linked SSO identities (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.listIdentities, -) -adminRouter.delete( - '/account/identities/:provider', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Unlink an SSO identity (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('provider').matches(/^[a-z0-9-]+$/), - validate, - account.unlinkIdentity, -) - // ── 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. @@ -979,89 +904,6 @@ adminRouter.post( emailConfig.disconnect, ) -// ── Authentication providers / SSO (admin only) ─────────────────────── -adminRouter.get( - '/auth/providers', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'List configured SSO providers (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */ - /* #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, - authProviders.list, -) -adminRouter.post( - '/auth/providers', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Create a custom SSO provider (admin only)' - // #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', 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" } } } } */ - /* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - body('id').matches(/^[a-z0-9-]+$/), - body('kind').isIn(['oidc', 'oauth2']), - body('name').isString().trim().notEmpty().isLength({ max: 80 }), - body('enabled').optional().isBoolean(), - body('clientId').optional({ values: 'falsy' }).isString(), - body('secret').optional({ values: 'falsy' }).isString(), - body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), - body('priority').optional().isInt(), - validate, - authProviders.create, -) -adminRouter.put( - '/auth/providers/:id', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Update an SSO provider (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ - /* #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" } } } } */ - /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').matches(/^[a-z0-9-]+$/), - body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }), - body('enabled').optional().isBoolean(), - body('clientId').optional({ values: 'falsy' }).isString(), - body('secret').optional({ values: 'falsy' }).isString(), - body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), - body('priority').optional().isInt(), - validate, - authProviders.update, -) -adminRouter.delete( - '/auth/providers/:id', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Delete a custom SSO provider (admin only)' - // #swagger.description = 'Built-in providers cannot be deleted — disable them instead.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */ - /* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', 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" } } } } */ - /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').matches(/^[a-z0-9-]+$/), - validate, - authProviders.remove, -) - // ── Moderation dashboard (admin + moderator) ────────────────────────── // Read-only views over the bot's mod_actions log, plus staff notes. The whole // sub-path is gated for the moderator role (admins included). @@ -1214,270 +1056,6 @@ adminRouter.get( moderation.getUserAppeals, ) -// ── User management (admin only) ────────────────────────────────────── -adminRouter.use('/users', adminOnly) -adminRouter.get( - '/users', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'List users (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */ - /* #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" } } } } */ - ctrl.listUsers, -) -adminRouter.post( - '/users', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Create a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #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" } } } } */ - /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('username').isString().trim().isLength({ min: 3, max: 32 }), - body('password').isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), - body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), - body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), - validate, - ctrl.createUser, -) -adminRouter.put( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Update a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', 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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), - body('password').optional().isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), - body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), - body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }), - validate, - ctrl.updateUser, -) -adminRouter.delete( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Delete a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ - /* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', 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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.deleteUser, -) - -// ── A user's trusted devices & MFA (admin only) ─────────────────────── -adminRouter.get( - '/users/:id/trusted-devices', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'List a user’s trusted devices (admin only)' - // #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */ - /* #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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.listUserTrustedDevices, -) -adminRouter.delete( - '/users/:id/trusted-devices', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */ - /* #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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.revokeAllUserTrustedDevices, -) -adminRouter.delete( - '/users/:id/trusted-devices/:deviceId', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - // #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' } - /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ - /* #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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - param('deviceId').isInt({ min: 1 }), - validate, - ctrl.revokeUserTrustedDevice, -) -adminRouter.post( - '/users/:id/mfa/reset', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Reset a user’s MFA (admin only)' - // #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ - /* #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" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.resetUserMfa, -) - -// ── User → shard (uo-link) footprint (admin only) ───────────────────── -// Backs the /admin/users/:id detail page: a user's linked game accounts and, -// scoped to those accounts, their vendor sales / houses / online characters. -// Live character rosters are fetched by the client through /admin/shard/* (which -// already grants admins a bypass to any account), so no routes for them here. -adminRouter.get( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Get a single user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getUser, -) -adminRouter.get( - '/users/:id/shard/accounts', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s linked game accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.listAccounts, -) -adminRouter.get( - '/users/:id/shard/sales', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getSales, -) -adminRouter.get( - '/users/:id/shard/houses', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getHouses, -) -adminRouter.get( - '/users/:id/shard/online', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s characters currently online (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getOnline, -) -adminRouter.get( - '/users/:id/shard/standing', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getStanding, -) -adminRouter.delete( - '/users/:id/shard/link/:account', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Unlink a game account from this user (admin only)' - // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ - /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').isInt(), - param('account').matches(SHARD_ACCOUNT_RE), - validate, - usersShard.unlinkAccount, -) - -// ── Email invites (admin only) ───────────────────────────────────────────── -adminRouter.post( - '/invites', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'Create and email an account invite at a chosen access level' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ - /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - adminOnly, - body('email').isEmail().isLength({ max: 255 }), - body('role').isIn(['admin', 'editor', 'moderator', 'player']), - validate, - invites.create, -) -adminRouter.get( - '/invites', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'List recent invites (no tokens)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - adminOnly, - invites.list, -) -adminRouter.delete( - '/invites/:id', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'Revoke a pending invite' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' } - /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').isInt(), - validate, - invites.revoke, -) - // ── 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). diff --git a/server/src/router/v1/admin/authProviders.router.js b/server/src/router/v1/admin/authProviders.router.js new file mode 100644 index 0000000..001f615 --- /dev/null +++ b/server/src/router/v1/admin/authProviders.router.js @@ -0,0 +1,102 @@ +// Admin · Auth Providers — SSO/OAuth2/OIDC provider configuration. +// +// Mounted at /api/v1/admin/auth by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`; the routes below are /providers under that, +// so the emitted URLs stay /api/v1/admin/auth/providers[/:id]. +// +// Admin-only: these rows carry client secrets (write-only, AES-GCM at rest via +// utils/secretBox.js) and decide which external identities may sign in at all. + +const express = require('express') +const { body, param } = require('express-validator') + +const authProviders = require('./authProviders.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +const providersRouter = express.Router() +const adminOnly = requireRole('admin') + +providersRouter.get( + '/providers', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'List configured SSO providers (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */ + /* #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, + authProviders.list, +) +providersRouter.post( + '/providers', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Create a custom SSO provider (admin only)' + // #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', 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" } } } } */ + /* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('id').matches(/^[a-z0-9-]+$/), + body('kind').isIn(['oidc', 'oauth2']), + body('name').isString().trim().notEmpty().isLength({ max: 80 }), + body('enabled').optional().isBoolean(), + body('clientId').optional({ values: 'falsy' }).isString(), + body('secret').optional({ values: 'falsy' }).isString(), + body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), + body('priority').optional().isInt(), + validate, + authProviders.create, +) +providersRouter.put( + '/providers/:id', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Update an SSO provider (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ + /* #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" } } } } */ + /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').matches(/^[a-z0-9-]+$/), + body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }), + body('enabled').optional().isBoolean(), + body('clientId').optional({ values: 'falsy' }).isString(), + body('secret').optional({ values: 'falsy' }).isString(), + body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), + body('priority').optional().isInt(), + validate, + authProviders.update, +) +providersRouter.delete( + '/providers/:id', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Delete a custom SSO provider (admin only)' + // #swagger.description = 'Built-in providers cannot be deleted — disable them instead.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */ + /* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', 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" } } } } */ + /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').matches(/^[a-z0-9-]+$/), + validate, + authProviders.remove, +) + +module.exports = providersRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js new file mode 100644 index 0000000..5887d88 --- /dev/null +++ b/server/src/router/v1/admin/index.js @@ -0,0 +1,46 @@ +// /api/v1/admin — the admin surface, assembled from per-capability routers. +// +// This file owns exactly two things: the gate every admin route shares, and the +// mount table. No route is declared here. Each capability router mounts at the +// prefix it already owned inside the old monolithic admin.routes.js, so the +// 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. + +const express = require('express') + +const { isLoggedIn, requireRole } = require('../../../utils/auth') +const noindex = require('../../../middleware/noindex') + +const accountRouter = require('./account.router') +const usersRouter = require('./users.router') +const invitesRouter = require('./invites.router') +const authProvidersRouter = require('./authProviders.router') +const residualRouter = require('./admin.routes') + +const adminRouter = express.Router() + +// Every admin route requires auth, a STAFF role, and is kept out of search +// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted +// role: without it, the editor-tier routes below (dashboard, posts, wiki, +// uploads) that are only guarded by isLoggedIn would be reachable by players. +// Players get 403 here and use the self-scoped /player group instead. +// +// It lives here, ahead of every mount, so a capability router extracted in a +// later PR cannot silently ship without it. +const staffOnly = requireRole('admin', 'editor', 'moderator') +adminRouter.use(noindex, isLoggedIn, staffOnly) + +adminRouter.use('/account', accountRouter) +adminRouter.use('/users', usersRouter) +adminRouter.use('/invites', invitesRouter) +// Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the +// routes inside read as /providers[/:id]. +adminRouter.use('/auth', authProvidersRouter) + +// 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) + +module.exports = adminRouter diff --git a/server/src/router/v1/admin/invites.router.js b/server/src/router/v1/admin/invites.router.js new file mode 100644 index 0000000..d6c12da --- /dev/null +++ b/server/src/router/v1/admin/invites.router.js @@ -0,0 +1,54 @@ +// Admin · Invites — create, list and revoke emailed account invites. +// +// Mounted at /api/v1/admin/invites by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Issuing an invite picks the new account's +// role, so it is admin-only — otherwise an editor could mint an admin. + +const express = require('express') +const { body, param } = require('express-validator') + +const invites = require('./invites.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +const invitesRouter = express.Router() +const adminOnly = requireRole('admin') + +invitesRouter.post( + '/', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Create and email an account invite at a chosen access level' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ + /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + adminOnly, + body('email').isEmail().isLength({ max: 255 }), + body('role').isIn(['admin', 'editor', 'moderator', 'player']), + validate, + invites.create, +) +invitesRouter.get( + '/', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'List recent invites (no tokens)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + adminOnly, + invites.list, +) +invitesRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Revoke a pending invite' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' } + /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + validate, + invites.revoke, +) + +module.exports = invitesRouter diff --git a/server/src/router/v1/admin/users.router.js b/server/src/router/v1/admin/users.router.js new file mode 100644 index 0000000..5fc7700 --- /dev/null +++ b/server/src/router/v1/admin/users.router.js @@ -0,0 +1,249 @@ +// Admin · Users — user management, MFA recovery, and a user's shard footprint. +// +// Mounted at /api/v1/admin/users by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors +// and moderators manage content and reports, never accounts. +// +// Handlers still live in admin.controller.js (users) and usersShard.controller.js +// (uo-link footprint); this PR re-wires routes, not logic. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./admin.controller') +const usersShard = require('./usersShard.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +// Same shape the shard routes validate account names with. +const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ + +const usersRouter = express.Router() +const adminOnly = requireRole('admin') + +usersRouter.use(adminOnly) +usersRouter.get( + '/', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'List users (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */ + /* #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" } } } } */ + ctrl.listUsers, +) +usersRouter.post( + '/', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Create a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #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" } } } } */ + /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('username').isString().trim().isLength({ min: 3, max: 32 }), + body('password').isString().isLength({ min: 8, max: 64 }), + body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), + body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), + body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), + validate, + ctrl.createUser, +) +usersRouter.put( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Update a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', 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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), + body('password').optional().isString().isLength({ min: 8, max: 64 }), + body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), + body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), + body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }), + validate, + ctrl.updateUser, +) +usersRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Delete a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ + /* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', 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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.deleteUser, +) + +// ── A user's trusted devices & MFA (admin only) ─────────────────────── +usersRouter.get( + '/:id/trusted-devices', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'List a user’s trusted devices (admin only)' + // #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */ + /* #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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.listUserTrustedDevices, +) +usersRouter.delete( + '/:id/trusted-devices', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */ + /* #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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.revokeAllUserTrustedDevices, +) +usersRouter.delete( + '/:id/trusted-devices/:deviceId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' } + /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ + /* #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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('deviceId').isInt({ min: 1 }), + validate, + ctrl.revokeUserTrustedDevice, +) +usersRouter.post( + '/:id/mfa/reset', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Reset a user’s MFA (admin only)' + // #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ + /* #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" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.resetUserMfa, +) + +// ── User → shard (uo-link) footprint (admin only) ───────────────────── +// Backs the /admin/users/:id detail page: a user's linked game accounts and, +// scoped to those accounts, their vendor sales / houses / online characters. +// Live character rosters are fetched by the client through /admin/shard/* (which +// already grants admins a bypass to any account), so no routes for them here. +usersRouter.get( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Get a single user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getUser, +) +usersRouter.get( + '/:id/shard/accounts', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked game accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.listAccounts, +) +usersRouter.get( + '/:id/shard/sales', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getSales, +) +usersRouter.get( + '/:id/shard/houses', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getHouses, +) +usersRouter.get( + '/:id/shard/online', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s characters currently online (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getOnline, +) +usersRouter.get( + '/:id/shard/standing', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getStanding, +) +usersRouter.delete( + '/:id/shard/link/:account', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Unlink a game account from this user (admin only)' + // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ + /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + param('account').matches(SHARD_ACCOUNT_RE), + validate, + usersShard.unlinkAccount, +) + +module.exports = usersRouter diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index 4f9edf8..98e30dc 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -4,7 +4,7 @@ const v1Router = express.Router() const authRouter = require('./auth/auth.routes') const publicRouter = require('./public/public.routes') -const adminRouter = require('./admin/admin.routes') +const adminRouter = require('./admin') const playerRouter = require('./player/player.routes') v1Router.use('/auth', authRouter)