Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
180 lines
12 KiB
JavaScript
180 lines
12 KiB
JavaScript
// 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 live in admin.controller.js. The shard footprint that used to be
|
||
// wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
|
||
// this file.
|
||
|
||
const express = require('express')
|
||
const { body, param } = require('express-validator')
|
||
|
||
const ctrl = require('./admin.controller')
|
||
const registries = require('../../../modules/registries')
|
||
const { requireRole } = require('../../../utils/auth')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
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,
|
||
)
|
||
|
||
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,
|
||
ctrl.getUser,
|
||
)
|
||
|
||
// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
|
||
//
|
||
// A module may hang routes off this core resource. Core DECLARES the slot; only
|
||
// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
|
||
// today is core's own usersShard.router.js, registered in registries.js's
|
||
// registerCore() — the shard footprint that used to be wired inline right here.
|
||
// Phase 3 changes the registrant, not this line.
|
||
//
|
||
// LAST, deliberately: every core route on the resource is already declared, so
|
||
// first-match-wins means core owns any path conflict. The router is created at
|
||
// declare time and filled later, because this file is required while app.js is
|
||
// still being built — long before a module has been scanned.
|
||
usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
|
||
|
||
module.exports = usersRouter
|