Files
website/server/src/router/v1/player/player.routes.js
wtclaude 14dfc122ba
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 9m28s
fix(player): open the player self-service surface to staff
Staff are a superset of players — every player ability plus their staff
tools on top — but the /player/* group ran requireRole('player'), so a
signed-in admin/editor/moderator got 403 on their own linked game
accounts (e.g. GET /player/shard/accounts). On the Android client this
hid "My characters" and greyed the personal notification streams for
staff accounts, even when they had linked characters.

Drop the role gate: the group is now requireAuth-only. Every handler is
already self-scoped to the caller by req.user.id (with the pre-existing
isAdmin bypass still letting a genuine admin read any character), so this
only ever widens access to the caller's OWN data. Staff also reach the
identical self-scoped handlers under /admin/shard/* (same controller).

- player.routes.js: requireRole('player') -> requireAuth; corrected the
  five stale "Player role required" 403 descriptions and regenerated
  swagger-output.json.
- New test/playerRouteAccess.test.js mounts the router and asserts
  player/admin/editor/moderator all reach the handler, anon still 401s,
  and a disabled account still 403s. Suite: 420 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:17:29 -05:00

297 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Player self-service (any authenticated account) ─────────────────────────
//
// The player self-service surface: linked game accounts, character/vendor/house
// reads, and account-credential changes, all self-scoped to the caller by
// req.user.id. Staff are a *superset* of players — they have every player ability
// plus their staff tools on top — so this group is open to any authenticated
// account, not just role 'player'. Staff also reach the identical self-scoped
// handlers under /admin/shard (they are the same controller); this group lets a
// staff account use the player surface directly. Handlers are shared with the
// admin account view (account.controller) — the same TOTP / identity logic, plus
// the net-new self-scoped credential changes. Future self-service endpoints hang
// off this group.
const express = require('express')
const { body, param } = require('express-validator')
const account = require('../admin/account.controller')
const shard = require('./shard.controller')
const appeals = require('./appeals.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const playerRouter = express.Router()
// Group gate: authenticated only (no role restriction) — players and staff alike
// use this self-service surface; every read/write is scoped to the caller. Keep it
// out of search indexes. requireAuth also enforces the account status check (a
// disabled/banned account is rejected here with 403 before any handler runs).
playerRouter.use(noindex, requireAuth)
playerRouter.get(
'/account',
// #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,
)
playerRouter.patch(
'/account/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,
)
playerRouter.patch(
'/account/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).
playerRouter.post(
'/account/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,
)
playerRouter.post(
'/account/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,
)
playerRouter.post(
'/account/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).
playerRouter.get(
'/account/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,
)
playerRouter.delete(
'/account/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,
)
// ── Game account linking (uo-link) ─────────────────────────────────────────
// Link an in-game account with a one-time code from [link, then read the
// account's roster / vendors (ownership-checked against the local link mirror).
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
playerRouter.post(
'/shard/link',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Link an in-game account with a one-time code'
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
shard.link,
)
playerRouter.post(
'/shard/account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
shard.createGameAccount,
)
playerRouter.get(
'/shard/accounts',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'List the callers linked game accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
shard.listAccounts,
)
playerRouter.get(
'/shard/roster/:account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Character roster for a linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('account').matches(ACCOUNT_RE),
validate,
shard.roster,
)
playerRouter.get(
'/shard/vendors/:account',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Player vendors for a linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('account').matches(ACCOUNT_RE),
validate,
shard.vendors,
)
playerRouter.get(
'/shard/char/:serial',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Character sheet — only for a character on the callers linked account'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('serial').matches(/^0x[0-9a-fA-F]+$/),
validate,
shard.getChar,
)
playerRouter.get(
'/shard/sales',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'Recent player-vendor sales for the callers linked accounts'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
shard.getSales,
)
playerRouter.get(
'/shard/houses',
// #swagger.tags = ['Player · Shard']
// #swagger.summary = 'The callers own houses (home status)'
// #swagger.description = 'Houses owned by the callers linked accounts, with decay/IDOC status. Only the callers own houses — never anyone elses.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
shard.getHouses,
)
// ── Moderation appeals (uo-link / Discord moderation) ──────────────────────
// A player appeals one of their own ban/mute mod_actions. Ownership is proven by
// matching the action against the caller's linked Discord identity.
playerRouter.get(
'/appeals',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'List the callers moderation appeals'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers appeals', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Appeal" } } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
appeals.listMine,
)
playerRouter.get(
'/appeals/eligible',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'List the callers ban/mute actions eligible for appeal'
// #swagger.description = 'The callers ban/mute mod_actions that have no active appeal. Returns an empty array when the caller has no linked Discord account (the UI shows a “link Discord” hint).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Appealable actions', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AppealEligibleAction" } } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
appeals.listEligible,
)
playerRouter.post(
'/appeals',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'Submit a moderation appeal for one of the callers actions'
// #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the callers linked Discord identity) and has no active appeal.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CreateAppealRequest" } } } } */
/* #swagger.responses[201] = { description: 'Appeal created', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
/* #swagger.responses[400] = { description: 'Validation error, or the action type is not appealable', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'The action does not belong to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Mod action not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'An appeal for this action is already open', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
accountChangeLimiter,
body('mod_action_id').isInt({ min: 1 }).toInt(),
body('submitted_text').isString().trim().isLength({ min: 1, max: 4000 }),
validate,
appeals.create,
)
playerRouter.post(
'/appeals/:id/withdraw',
// #swagger.tags = ['Player · Appeals']
// #swagger.summary = 'Withdraw one of the callers pending appeals'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Appeal id (must belong to the caller).' }
/* #swagger.responses[200] = { description: 'The withdrawn appeal', content: { "application/json": { schema: { $ref: "#/components/schemas/Appeal" } } } } */
/* #swagger.responses[404] = { description: 'No such appeal for the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Appeal is already resolved', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
validate,
appeals.withdraw,
)
module.exports = playerRouter