refactor(server): split public, player and residual auth into capability routers (PR 5)
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:
130
server/src/router/v1/player/account.router.js
Normal file
130
server/src/router/v1/player/account.router.js
Normal 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 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,
|
||||
)
|
||||
|
||||
accountRouter.patch(
|
||||
'/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).
|
||||
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
|
||||
@@ -4,7 +4,8 @@
|
||||
// one that hasn't been resolved yet. Ownership is proven by matching the
|
||||
// action's target_user_id against the caller's linked Discord identity — the
|
||||
// same (provider='discord', subject=<snowflake>) link the SSO flow writes.
|
||||
// Mounted behind the player-role gate (see player.routes.js).
|
||||
// Mounted behind the /player group gate — authenticated only, no role restriction
|
||||
// (see player/index.js); ownership is enforced per handler.
|
||||
const appeals = require('../../../model/appeals/appeals.model')
|
||||
const appealsDb = require('../../../model/appeals/appeals.db')
|
||||
const { isAppealableType, isTerminal } = require('../../../model/appeals/appeals.pure')
|
||||
|
||||
73
server/src/router/v1/player/appeals.router.js
Normal file
73
server/src/router/v1/player/appeals.router.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// Player · Appeals — 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 (see appeals.controller); the staff side of the queue lives in
|
||||
// admin/moderation.router.js.
|
||||
//
|
||||
// Mounted at /api/v1/player/appeals by player/index.js, which already applied
|
||||
// `noindex, requireAuth`. No extra gate — every handler is self-scoped.
|
||||
//
|
||||
// Declaration order: GET /eligible is a literal path and sits ahead of the only
|
||||
// :param route (POST /:id/withdraw), which is a different method at a different
|
||||
// depth, so nothing here can shadow anything else.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const appeals = require('./appeals.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
|
||||
|
||||
const appealsRouter = express.Router()
|
||||
|
||||
appealsRouter.get(
|
||||
'/',
|
||||
// #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,
|
||||
)
|
||||
appealsRouter.get(
|
||||
'/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,
|
||||
)
|
||||
appealsRouter.post(
|
||||
'/',
|
||||
// #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,
|
||||
)
|
||||
appealsRouter.post(
|
||||
'/: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 = appealsRouter
|
||||
43
server/src/router/v1/player/index.js
Normal file
43
server/src/router/v1/player/index.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// /api/v1/player — the player self-service surface, assembled from
|
||||
// per-capability routers.
|
||||
//
|
||||
// This file owns exactly two things: the gate every player 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 player.routes.js, so the
|
||||
// emitted URL set is byte-identical — proved by a zero-line diff in
|
||||
// server/routes.manifest.json (`npm run routes:manifest`).
|
||||
//
|
||||
// **Staff are a superset of players.** This group is open to any authenticated
|
||||
// account, not just role 'player': every read/write is self-scoped to req.user.id,
|
||||
// and a staff member has every player ability plus their staff tools on top.
|
||||
// Adding a requireRole('player') here would 403 an admin off their own characters
|
||||
// (it happened once — see docs/website/BACKEND_DESIGN.md). Staff also reach the
|
||||
// identical self-scoped handlers under /admin/shard and /auth/me/account; those
|
||||
// are alternative URLs onto the same controllers, not duplicated logic.
|
||||
//
|
||||
// See docs/website/API_V2_PLAN.md § Phase 2 for the split.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const shardRouter = require('./shard.router')
|
||||
const appealsRouter = require('./appeals.router')
|
||||
|
||||
const playerRouter = express.Router()
|
||||
|
||||
// Group gate: authenticated only (no role restriction). 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).
|
||||
//
|
||||
// It lives here, ahead of every mount, so a capability router added later cannot
|
||||
// silently ship without it.
|
||||
playerRouter.use(noindex, requireAuth)
|
||||
|
||||
playerRouter.use('/account', accountRouter)
|
||||
playerRouter.use('/shard', shardRouter)
|
||||
playerRouter.use('/appeals', appealsRouter)
|
||||
|
||||
module.exports = playerRouter
|
||||
@@ -1,296 +0,0 @@
|
||||
// ── 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
|
||||
124
server/src/router/v1/player/shard.router.js
Normal file
124
server/src/router/v1/player/shard.router.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Player · Shard — game-account linking and the caller's own roster / vendors /
|
||||
// characters / sales / houses, ownership-checked against the local link mirror.
|
||||
//
|
||||
// Mounted at /api/v1/player/shard by player/index.js, which already applied
|
||||
// `noindex, requireAuth`. No extra gate: every handler is self-scoped to
|
||||
// req.user.id.
|
||||
//
|
||||
// These are the *same* handlers (player/shard.controller) that admin/shard.router.js
|
||||
// serves under /admin/shard for the seven self-service routes — staff are a
|
||||
// superset of players, and the controller keys off req.user.id either way. Two
|
||||
// URL surfaces, one implementation.
|
||||
|
||||
const express = require('express')
|
||||
const { body, param } = require('express-validator')
|
||||
|
||||
const shard = require('./shard.controller')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
// 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}$/
|
||||
|
||||
shardRouter.post(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/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 = shardRouter
|
||||
Reference in New Issue
Block a user