4 Commits

Author SHA1 Message Date
0e11e28cca Merge pull request 'build(swagger): normalize and sort generated OpenAPI path keys' (#101) from build/swagger-normalize-paths into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m38s
Reviewed-on: #101
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:57 +00:00
f7c98b8ba3 Merge pull request 'refactor(server): split admin users, account, invites and auth providers into capability routers' (#102) from refactor/admin-router-split-1 into build/swagger-normalize-paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 41s
Reviewed-on: #102
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:03 +00:00
8ad892725f refactor(server): split admin users, account, invites and auth providers into capability routers
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 <noreply@anthropic.com>
2026-07-27 15:54:00 -05:00
1a61cd1638 build(swagger): normalize and sort generated OpenAPI path keys
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m16s
Prepares the committed spec for the admin router domain split
(docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's
output in swagger/swagger.js. No route, handler or annotation changes.

Trailing slashes are stripped from path keys. swagger-autogen builds a path by
string-concatenating the mount prefix with the route argument, so a capability
router mounted at /users whose collection route is router.get('/') documents as
/api/v1/admin/users/ — advertising a URL no client calls while dropping the one
the SPA, the Android app and the Discord bot all do. Express is indifferent
(non-strict routing treats the two as one route, and routes.manifest.json records
the canonical slash-less form), but the published spec is a contract. The split
creates one of these per capability router, so it is fixed once here rather than
by contorting the route declarations in every router file.

Path keys are also sorted. The generator emits them in router-traversal order, so
moving a route between files rewrites most of this ~5k-line committed artifact
even when the API is provably unchanged, burying the one line a reviewer needs to
see. OpenAPI attaches no meaning to path order, and scripts/routeManifest.js
already sorts for the same reason.

Verified inert: the regenerated spec is byte-for-byte the sorted form of the
previously committed one — same 198 operations, zero added or removed, and no
trailing-slash keys (there were none to strip yet; the guard is for the split).
A collision after normalization throws rather than silently dropping an
operation. Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:49:31 -05:00
9 changed files with 8721 additions and 8564 deletions

View File

@@ -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

View File

@@ -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 express = require('express')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
@@ -6,32 +17,19 @@ const multer = require('multer')
const { body, param } = require('express-validator') const { body, param } = require('express-validator')
const ctrl = require('./admin.controller') const ctrl = require('./admin.controller')
const account = require('./account.controller')
const botActivity = require('./botActivity.controller') const botActivity = require('./botActivity.controller')
const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller') const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller') const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller') const uoLink = require('./uoLink.controller')
const shardOps = require('./shardOps.controller') const shardOps = require('./shardOps.controller')
const usersShard = require('./usersShard.controller')
const invites = require('./invites.controller')
const selfShard = require('../player/shard.controller') const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller') const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller') const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const adminRouter = express.Router() 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 // Admin-only gate. Editors may manage content (posts/wiki), but user
// management, site mode, and settings are restricted to the admin role. // management, site mode, and settings are restricted to the admin role.
const adminOnly = requireRole('admin') const adminOnly = requireRole('admin')
@@ -41,79 +39,6 @@ const adminOnly = requireRole('admin')
// admin check inside the controller. // admin check inside the controller.
const modAccess = requireRole('admin', 'moderator') 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) ─────────────── // ── Game account linking (self-service, any staff role) ───────────────
// Staff link their OWN in-game account here, exactly like players do under // 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. // /player/shard. The controller keys off req.user.id, so the same handlers work.
@@ -979,89 +904,6 @@ adminRouter.post(
emailConfig.disconnect, 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) ────────────────────────── // ── Moderation dashboard (admin + moderator) ──────────────────────────
// Read-only views over the bot's mod_actions log, plus staff notes. The whole // 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). // sub-path is gated for the moderator role (admins included).
@@ -1214,270 +1056,6 @@ adminRouter.get(
moderation.getUserAppeals, 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 users trusted devices (admin only)'
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that users 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 users 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 users 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 users 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 users 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 users 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 users 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 users 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 users 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 accounts 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) ────────────────────────────────── // ── uo-link sidecar control (admin only) ──────────────────────────────────
// Connection config (base/ws URL + token + protocol + enabled) and the town // Connection config (base/ws URL + token + protocol + enabled) and the town
// crier. The token is write-only (SECURITY note in uoLink.controller.js). // crier. The token is write-only (SECURITY note in uoLink.controller.js).

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 users trusted devices (admin only)'
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that users 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 users 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 users 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 users 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 users 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 users 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 users 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 users 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 users 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 accounts 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

View File

@@ -4,7 +4,7 @@ const v1Router = express.Router()
const authRouter = require('./auth/auth.routes') const authRouter = require('./auth/auth.routes')
const publicRouter = require('./public/public.routes') const publicRouter = require('./public/public.routes')
const adminRouter = require('./admin/admin.routes') const adminRouter = require('./admin')
const playerRouter = require('./player/player.routes') const playerRouter = require('./player/player.routes')
v1Router.use('/auth', authRouter) v1Router.use('/auth', authRouter)

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
// Regenerate with: npm run swagger (from the server/ directory) // Regenerate with: npm run swagger (from the server/ directory)
// The generated JSON is committed so the docs work without a build step. // The generated JSON is committed so the docs work without a build step.
const fs = require('fs')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const pkg = require('../package.json') const pkg = require('../package.json')
const brand = require('../src/config/brand') const brand = require('../src/config/brand')
@@ -916,7 +918,46 @@ const doc = {
}, },
} }
/**
* Normalize `/a/b/` → `/a/b` in the generated path keys.
*
* swagger-autogen builds a path by string-concatenating the mount prefix with the
* route argument, so a capability router mounted at `/users` that declares its
* collection route as `router.get('/')` documents as `/api/v1/admin/users/`.
* Express itself does not care (non-strict routing treats the two as one route,
* and server/routes.manifest.json records the canonical slash-less form), but the
* *spec* would advertise a URL no client uses and stop documenting the one they
* all call. The domain split (docs/website/API_V2_PLAN.md § Phase 2) creates one
* of these per capability router, so it is fixed here once rather than by
* contorting the route declarations in every router file.
*
* The path keys are also **sorted**. swagger-autogen emits them in router-traversal
* order, so moving a route between files rewrites most of this 5k-line committed
* artifact even when the API is provably unchanged — burying the one line a
* reviewer needs to see. OpenAPI attaches no meaning to path order, and
* scripts/routeManifest.js already sorts for the same reason.
*/
function normalizePaths(spec) {
const paths = {}
for (const [p, item] of Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
const key = p.length > 1 ? p.replace(/\/+$/, '') : p
if (paths[key]) {
// Two different declarations collapsed onto one path — merging would hide
// whichever lost. Nothing in the tree does this today; fail loudly if it starts.
throw new Error(
`swagger: "${p}" and "${key}" collide after trailing-slash normalization. ` +
'Two routes are documenting the same URL — reconcile them in the router.',
)
}
paths[key] = item
}
spec.paths = paths
return spec
}
swaggerAutogen(outputFile, routes, doc).then(() => { swaggerAutogen(outputFile, routes, doc).then(() => {
const written = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('swagger-output.json generated.') console.log('swagger-output.json generated.')
}) })