refactor(server): split public, player and residual auth into capability routers (PR 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s

The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

  public/  posts (2) wiki (4) pages (2) shard (12) site (4, group root)
  player/  account (8) shard (8) appeals (4), behind noindex + requireAuth
  auth/    login (2) register (1) invite (2) password (3) session (2, root)

No URL moves. All four gates zero-diff: routes.manifest.json (200 public + 2
internal), routes.guards.json, swagger-output.json (198 operations), and
docs/website/api-route-inventory.json was already in sync. 434 tests green.

Notes on the non-mechanical parts:

- public/index.js and auth/index.js carry no group gate, deliberately, and say
  so. The public surface is anonymous by contract (logged-out SPA, Discord bot,
  Android ShardStreamClient on /public/shard/stream); /auth is where a caller
  becomes authenticated. player/index.js gates on requireAuth only, never
  requireRole('player') — staff are a superset of players.
- GET /auth/me has a mount-order dependency: use('/me', meRouter) matches the
  bare /me, so the request runs meRouter's noindex + requireAuth and falls
  through. session.router.js must stay mounted last. Verified by the
  counterfactual — mounting it first still 401s but drops X-Robots-Tag, which
  no manifest or guards file can see.
- loginGuards moved to auth/loginGuards.js (frozen) rather than being copied
  into the three routers that spread it; sso.routes.js drops its duplicate.
- The :param shadowing check was re-run in dispatch order against the built
  stack: 86 routes, 64 literal, none shadowed. /public/wiki/{categories,tags}
  ahead of /:slug is the only ordering-sensitive pair.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:52:14 -05:00
parent 3fcc64ab96
commit 565a7d2c20
30 changed files with 1126 additions and 775 deletions

View File

@@ -0,0 +1,130 @@
// Player · Account — self-service credentials, 2FA and linked identities for the
// signed-in account.
//
// Mounted at /api/v1/player/account by player/index.js, which already applied
// `noindex, requireAuth`. No extra gate: every handler is self-scoped to
// req.user.id, and staff are a superset of players (see player/index.js).
//
// The handlers are admin/account.controller — the same code serving
// /admin/account/* and /auth/me/account/*. Three URL surfaces, one implementation;
// this file must not grow a fourth copy of the logic.
//
// The swagger tag stays 'Player', matching the committed spec.
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const accountRouter = express.Router()
accountRouter.get(
'/',
// #swagger.tags = ['Player']
// #swagger.summary = 'Get the current player account (self)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.getAccount,
)
accountRouter.patch(
'/username',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change the current players username'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 3, max: 32 }),
validate,
account.changeUsername,
)
accountRouter.patch(
'/password',
// #swagger.tags = ['Player']
// #swagger.summary = 'Change or set the current players password'
// #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the callers session is re-issued (they stay logged in) while all other sessions are revoked.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */
/* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('newPassword').isString().isLength({ min: 8, max: 64 }),
body('currentPassword').optional({ values: 'falsy' }).isString(),
validate,
account.changePassword,
)
// TOTP self-enrollment — identical to the admin account flow (disable requires a
// valid current code; it does not take a password).
accountRouter.post(
'/totp/setup',
// #swagger.tags = ['Player']
// #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[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
account.totpSetup,
)
accountRouter.post(
'/totp/enable',
// #swagger.tags = ['Player']
// #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[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 = ['Player']
// #swagger.summary = 'Disable 2FA by confirming a code'
// #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.'
// #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" } } } } */
body('code').isString().trim().isLength({ min: 6, max: 8 }),
validate,
account.totpDisable,
)
// Linked SSO identities (self-service). Linking itself starts at
// GET /auth/sso/:provider/link (already behind requireAuth; works for players).
accountRouter.get(
'/identities',
// #swagger.tags = ['Player']
// #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" } } } } } */
account.listIdentities,
)
accountRouter.delete(
'/identities/:provider',
// #swagger.tags = ['Player']
// #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[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