// ── 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 player’s 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 player’s 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 caller’s 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 caller’s 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 caller’s 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 caller’s 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 caller’s own houses (home status)' // #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The caller’s 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 caller’s moderation appeals' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The caller’s 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 caller’s ban/mute actions eligible for appeal' // #swagger.description = 'The caller’s 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 caller’s actions' // #swagger.description = 'Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the caller’s 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 caller’s 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