Per request, split the single public house registry into three role-scoped views: - Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/ coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger"; kept live via the public house.decay feed. The full-registry deltas (house.update / house.remove — which carry owner/price) are REMOVED from the public SSE allowlist so they never reach the public channel. - Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS) backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay and search, kept live on the admin SSE channel. - Player portal → "My houses" home-status section (own houses only, with decay/ IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts. Server tests green, client build clean, swagger regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
16 KiB
JavaScript
236 lines
16 KiB
JavaScript
// ── Player self-service (role: 'player') ───────────────────────────────────
|
||
//
|
||
// The player-gated surface. Every route here requires an authenticated session
|
||
// whose fresh DB role is 'player' (staff use /admin/account for the same self-
|
||
// service). Handlers are shared with the admin account view (account.controller)
|
||
// — the same TOTP / identity logic, plus the net-new self-scoped credential
|
||
// changes. Future player-only endpoints (profile, etc.) 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 { requireAuth, requireRole } = 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 + fresh role must be 'player', and keep it out of
|
||
// search indexes. requireAuth also enforces the account status check (a
|
||
// disabled/banned player is rejected here with 403 before any handler runs).
|
||
playerRouter.use(noindex, requireAuth, requireRole('player'))
|
||
|
||
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: 'Player role required, or account not active', 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: 'Player role required, or account not active', 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: 'Player role required, or account not active', 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,
|
||
)
|
||
|
||
module.exports = playerRouter
|