diff --git a/client/src/api/client.js b/client/src/api/client.js index 9a97898..ff281f8 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -105,6 +105,24 @@ export const api = { revokeTrustedDevice: (id) => req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }), revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }), + // Self-service account security, role-agnostic under /auth/me/account. This is + // the ONLY surface for it: the /admin/account/* and /player/account/* copies + // were deleted (both were strictly smaller — neither carried recovery codes), + // which is why recovery codes below already lived here while the rest did not. + // The change endpoints re-issue the session cookie server-side, so the caller + // stays signed in. + myAccount: () => req('/auth/me/account'), + changeUsername: (username) => + req('/auth/me/account/username', { method: 'PATCH', body: { username } }), + changePassword: (newPassword, currentPassword) => + req('/auth/me/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), + totpSetup: () => req('/auth/me/account/totp/setup', { method: 'POST' }), + totpEnable: (code) => req('/auth/me/account/totp/enable', { method: 'POST', body: { code } }), + totpDisable: (code) => req('/auth/me/account/totp/disable', { method: 'POST', body: { code } }), + // Linked SSO identities (self-service). Linking starts at /auth/sso/:id/link. + myIdentities: () => req('/auth/me/account/identities'), + unlinkIdentity: (provider) => + req(`/auth/me/account/identities/${encodeURIComponent(provider)}`, { method: 'DELETE' }), // Recovery (backup) codes. status → remaining count; generate → a fresh set, // returned ONCE (password step-up for accounts that have a password). recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'), @@ -435,16 +453,6 @@ export const api = { req(`/admin/moderation/appeals/${id}/resolve`, { method: 'POST', body: data }), getUserAppeals: (discordId) => req(`/admin/moderation/user/${discordId}/appeals`), - // ----- account security (self-service 2FA) ----- - getAccount: () => req('/admin/account'), - totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), - totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }), - totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }), - - // ----- linked SSO identities (self-service) ----- - linkedIdentities: () => req('/admin/account/identities'), - unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }), - // ----- auth providers / SSO config (admin only) ----- listAuthProviders: () => req('/admin/auth/providers'), createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), @@ -465,20 +473,9 @@ export const api = { }, // ----- player self-service (role: 'player') ----- - // Mirrors the admin account methods but self-scoped under /player. The change - // endpoints re-issue the session cookie server-side, so the caller stays signed in. + // Account security is NOT here — it is role-agnostic and lives at the root of + // this object, on /auth/me/account. What remains is genuinely player-scoped. player: { - getAccount: () => req('/player/account'), - changeUsername: (username) => - req('/player/account/username', { method: 'PATCH', body: { username } }), - changePassword: (newPassword, currentPassword) => - req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), - totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }), - totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }), - totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }), - linkedIdentities: () => req('/player/account/identities'), - unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), - // ----- moderation appeals (self-service) ----- getMyAppeals: () => req('/player/appeals'), getEligibleAppeals: () => req('/player/appeals/eligible'), diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx index acdd108..b557e3c 100644 --- a/client/src/routes/admin/views/AccountAdmin.jsx +++ b/client/src/routes/admin/views/AccountAdmin.jsx @@ -25,7 +25,7 @@ function LinkedAccounts() { const load = useCallback(async () => { try { const [ids, avail] = await Promise.all([ - api.admin.linkedIdentities(), + api.myIdentities(), api.authProviders().catch(() => []), ]) setLinked(ids) @@ -44,7 +44,7 @@ function LinkedAccounts() { async function unlink(provider) { if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return try { - await api.admin.unlinkIdentity(provider) + await api.unlinkIdentity(provider) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -134,7 +134,7 @@ export default function AccountAdmin() { async function load() { try { - setAccount(await api.admin.getAccount()) + setAccount(await api.myAccount()) } catch { setError('Could not load your account.') } finally { @@ -154,7 +154,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - setSetup(await api.admin.totpSetup()) + setSetup(await api.totpSetup()) setCode('') } catch (err) { setError(err.message || 'Could not start setup.') @@ -168,7 +168,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - const res = await api.admin.totpEnable(code.trim()) + const res = await api.totpEnable(code.trim()) setSetup(null) setCode('') setNewCodes(res?.recoveryCodes || null) @@ -186,7 +186,7 @@ export default function AccountAdmin() { setMsg('') setError('') try { - await api.admin.totpDisable(code.trim()) + await api.totpDisable(code.trim()) setCode('') setMsg('Two-factor authentication has been disabled.') await load() diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx index a4c73db..01782b2 100644 --- a/client/src/routes/player/PlayerAccount.jsx +++ b/client/src/routes/player/PlayerAccount.jsx @@ -21,7 +21,7 @@ function ChangeUsername({ account, onChanged }) { if (username.trim().length < 3) return setError('Username must be at least 3 characters.') setBusy(true) try { - const { username: next } = await api.player.changeUsername(username.trim()) + const { username: next } = await api.changeUsername(username.trim()) setMsg('Username updated.') await onChanged(next) } catch (err) { @@ -67,7 +67,7 @@ function ChangePassword({ account }) { if (hasPassword && !current) return setError('Enter your current password.') setBusy(true) try { - await api.player.changePassword(next, hasPassword ? current : undefined) + await api.changePassword(next, hasPassword ? current : undefined) setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.') setCurrent('') setNext('') @@ -124,7 +124,7 @@ function TwoFactor({ account, reload }) { async function begin() { setBusy(true); setMsg(''); setError('') try { - setSetup(await api.player.totpSetup()) + setSetup(await api.totpSetup()) setCode('') } catch (err) { setError(err.message || 'Could not start setup.') @@ -135,7 +135,7 @@ function TwoFactor({ account, reload }) { async function confirm() { setBusy(true); setMsg(''); setError('') try { - const res = await api.player.totpEnable(code.trim()) + const res = await api.totpEnable(code.trim()) setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.') await reload() } catch (err) { @@ -147,7 +147,7 @@ function TwoFactor({ account, reload }) { async function disable() { setBusy(true); setMsg(''); setError('') try { - await api.player.totpDisable(code.trim()) + await api.totpDisable(code.trim()) setCode(''); setMsg('Two-factor has been disabled.') await reload() } catch (err) { @@ -234,7 +234,7 @@ function LinkedAccounts() { const load = useCallback(async () => { try { const [ids, avail] = await Promise.all([ - api.player.linkedIdentities(), + api.myIdentities(), api.authProviders().catch(() => []), ]) setLinked(ids) @@ -251,7 +251,7 @@ function LinkedAccounts() { async function unlink(provider) { if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return try { - await api.player.unlinkIdentity(provider) + await api.unlinkIdentity(provider) await load() } catch (err) { setError(err.message || 'Could not unlink.') @@ -397,7 +397,7 @@ export default function PlayerAccount() { const load = useCallback(async () => { try { - setAccount(await api.player.getAccount()) + setAccount(await api.myAccount()) } catch { setError('Could not load your account.') } finally { diff --git a/server/routes.guards.json b/server/routes.guards.json index 20e99fe..a4499fe 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -27,66 +27,6 @@ "handlers": 1, "gates": [] }, - { - "method": "GET", - "path": "/api/v1/admin/account", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/admin/account/identities", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/admin/account/identities/:provider", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/disable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/enable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/setup", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, { "method": "GET", "path": "/api/v1/admin/activity", @@ -1640,88 +1580,6 @@ "validate" ] }, - { - "method": "GET", - "path": "/api/v1/player/account", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "GET", - "path": "/api/v1/player/account/identities", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "DELETE", - "path": "/api/v1/player/account/identities/:provider", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/password", - "handlers": 5, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/disable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/enable", - "handlers": 3, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/setup", - "handlers": 1, - "gates": [ - "noindex", - "requireAuth" - ] - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/username", - "handlers": 4, - "gates": [ - "noindex", - "requireAuth", - "middleware", - "validate" - ] - }, { "method": "GET", "path": "/api/v1/player/appeals", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 1c98c66..ed7f851 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -17,30 +17,6 @@ "method": "GET", "path": "/api/health" }, - { - "method": "GET", - "path": "/api/v1/admin/account" - }, - { - "method": "GET", - "path": "/api/v1/admin/account/identities" - }, - { - "method": "DELETE", - "path": "/api/v1/admin/account/identities/:provider" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/disable" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/enable" - }, - { - "method": "POST", - "path": "/api/v1/admin/account/totp/setup" - }, { "method": "GET", "path": "/api/v1/admin/activity" @@ -657,38 +633,6 @@ "method": "POST", "path": "/api/v1/auth/sso/totp" }, - { - "method": "GET", - "path": "/api/v1/player/account" - }, - { - "method": "GET", - "path": "/api/v1/player/account/identities" - }, - { - "method": "DELETE", - "path": "/api/v1/player/account/identities/:provider" - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/password" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/disable" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/enable" - }, - { - "method": "POST", - "path": "/api/v1/player/account/totp/setup" - }, - { - "method": "PATCH", - "path": "/api/v1/player/account/username" - }, { "method": "GET", "path": "/api/v1/player/appeals" diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 0735782..1b6c598 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -156,9 +156,9 @@ function buildCtx(id, moduleRoot) { // one store, and one place a breach is logged. // // `accountChangeLimiter` is handed over whole because it is genuinely - // shared policy: core's `/auth/me`, `/player/account` and - // `/player/appeals` are behind the same counter, and a module's - // account-change route has to land in it rather than beside it. + // shared policy: core's `/auth/me/account/*` and `/player/appeals` are + // behind the same counter, and a module's account-change route has to land + // in it rather than beside it. rateLimit: makeLimiter, accountChangeLimiter, }, diff --git a/server/src/router/v1/admin/account.router.js b/server/src/router/v1/admin/account.router.js deleted file mode 100644 index c674d2f..0000000 --- a/server/src/router/v1/admin/account.router.js +++ /dev/null @@ -1,87 +0,0 @@ -// Admin · Account — self-service account security for staff. -// -// Mounted at /api/v1/admin/account by admin/index.js, which already applied -// `noindex, isLoggedIn, staffOnly`. Deliberately NOT behind adminOnly: an editor -// or moderator manages their own 2FA and linked identities here, exactly as a -// player does under /player. Every handler keys off req.user.id. - -const express = require('express') -const { body, param } = require('express-validator') - -const account = require('./account.controller') -const validate = require('../../../middleware/validate') - -const accountRouter = express.Router() - -accountRouter.get( - '/', - // #swagger.tags = ['Admin · Account'] - // #swagger.summary = 'Get the current account (self)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.getAccount, -) -accountRouter.post( - '/totp/setup', - // #swagger.tags = ['Admin · Account'] - // #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[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.totpSetup, -) -accountRouter.post( - '/totp/enable', - // #swagger.tags = ['Admin · Account'] - // #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[401] = { description: 'Not authenticated', 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 = ['Admin · Account'] - // #swagger.summary = 'Disable 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 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" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', 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 — any logged-in role manages their own). -accountRouter.get( - '/identities', - // #swagger.tags = ['Admin · Account'] - // #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" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - account.listIdentities, -) -accountRouter.delete( - '/identities/:provider', - // #swagger.tags = ['Admin · Account'] - // #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[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #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 diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 5e32048..56d0614 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -16,7 +16,6 @@ const express = require('express') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') -const accountRouter = require('./account.router') const usersRouter = require('./users.router') const invitesRouter = require('./invites.router') const authProvidersRouter = require('./authProviders.router') @@ -48,7 +47,6 @@ const adminRouter = express.Router() const staffOnly = requireRole('admin', 'editor', 'moderator') adminRouter.use(noindex, isLoggedIn, staffOnly) -adminRouter.use('/account', accountRouter) adminRouter.use('/users', usersRouter) adminRouter.use('/invites', invitesRouter) // Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the diff --git a/server/src/router/v1/admin/account.controller.js b/server/src/router/v1/auth/account.controller.js similarity index 96% rename from server/src/router/v1/admin/account.controller.js rename to server/src/router/v1/auth/account.controller.js index ef090b9..e260571 100644 --- a/server/src/router/v1/admin/account.controller.js +++ b/server/src/router/v1/auth/account.controller.js @@ -1,6 +1,13 @@ -// Self-service account security for the logged-in user (any role). Mounted under -// the admin router (so isLoggedIn has already run and req.user is the fresh DB -// row), but NOT behind the admin-only gate — editors manage their own 2FA too. +// Self-service account security for the logged-in user (any role): username, +// password, TOTP, linked identities, device sessions, trusted devices and +// recovery codes. +// +// Reached through exactly one router — me.routes.js at /auth/me — which applies +// `noindex, requireAuth`, so req.user is the fresh DB row and the status + +// session-cutoff checks have already run. Every handler keys off req.user.id and +// none of them consults a role: this file lived under router/v1/admin/ while it +// also served /admin/account/* and /player/account/*, and moved here when those +// two surfaces were deleted. const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') @@ -9,7 +16,7 @@ const mobileSessions = require('../../../model/mobileSessions/mobileSessions.mod const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model') const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model') const sessionService = require('../../../auth/session.service') -const { establishTrust } = require('../auth/trustDevice.helper') +const { establishTrust } = require('./trustDevice.helper') const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token') const usernamePolicy = require('../../../auth/usernamePolicy') const loginProtection = require('../../../middleware/loginProtection') diff --git a/server/src/router/v1/auth/index.js b/server/src/router/v1/auth/index.js index f4ed16b..58b78fc 100644 --- a/server/src/router/v1/auth/index.js +++ b/server/src/router/v1/auth/index.js @@ -38,10 +38,10 @@ authRouter.use('/mobile', mobileRouter) // middleware, so passing through it is a no-op for every other route. authRouter.use(ssoRouter) -// Role-agnostic self-service ("me") — /auth/me/account*, reusing the same -// account.controller handlers as /player/account/* and /admin/account/* behind -// requireAuth (any role). Additive; gives the app one self surface that never -// touches /admin. +// Role-agnostic self-service ("me") — /auth/me/account*, behind requireAuth (any +// role). The single self surface: /player/account/* and /admin/account/* were +// deleted in favour of it, so the app and the web client share one set of URLs +// and neither has to touch /admin. authRouter.use('/me', meRouter) // Push-notification self-service — /auth/me/devices*, /auth/me/notifications/*. diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js index 24bd3d0..f4777e2 100644 --- a/server/src/router/v1/auth/me.routes.js +++ b/server/src/router/v1/auth/me.routes.js @@ -1,14 +1,17 @@ // ── Role-agnostic self-service ("me") under /auth/me ─────────────────────── // -// The canonical self surface for EVERY authenticated role (player and staff -// alike). It reuses the exact same account.controller handlers as -// /player/account/* and /admin/account/* — no logic duplication — but gates on -// requireAuth ONLY (any authenticated, active account), never on a specific role. +// The ONLY self surface, for every authenticated role (player and staff alike). +// It gates on requireAuth ONLY (any authenticated, active account), never on a +// specific role. // // Why it exists: the Android app wants one self surface it can call regardless of -// role, and it must never touch /admin (docs/android/PLAN.md §6.4). The older -// /player/account/* and /admin/account/* routes stay for web back-compat; these -// /auth/me/* routes are the additive, role-agnostic canonical form. +// role, and it must never touch /admin (docs/android/PLAN.md §6.4). +// +// It used to be the third of three URL surfaces onto account.controller, beside +// /player/account/* and /admin/account/*. Those were deleted: both were strictly +// smaller than this one (neither carried recovery codes, and /admin/account +// carried no username or password change), so the web client already had to reach +// in here for part of one screen. New self-service fields go here and only here. // // requireAuth sets req.user to the fresh DB row and enforces the status + session // cutoff/revocation checks on every request, exactly as the account handlers @@ -17,7 +20,7 @@ const express = require('express') const { body, param } = require('express-validator') -const account = require('../admin/account.controller') +const account = require('./account.controller') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -76,8 +79,8 @@ meRouter.patch( account.changePassword, ) -// TOTP self-enrollment — identical to the player/admin account flow (disable -// requires a valid current code; it does not take a password). +// TOTP self-enrollment (disable requires a valid current code; it does not take +// a password). meRouter.post( '/account/totp/setup', // #swagger.tags = ['Auth · Me'] diff --git a/server/src/router/v1/auth/password.router.js b/server/src/router/v1/auth/password.router.js index 25d209e..5759a8e 100644 --- a/server/src/router/v1/auth/password.router.js +++ b/server/src/router/v1/auth/password.router.js @@ -11,7 +11,7 @@ // limiters below are what stop the endpoints being used as an oracle by volume. // // Changing a password while signed in is a different route — -// PATCH /player/account/password (and its /auth/me and /admin twins). +// PATCH /auth/me/account/password. const express = require('express') const { body, param } = require('express-validator') diff --git a/server/src/router/v1/player/account.router.js b/server/src/router/v1/player/account.router.js deleted file mode 100644 index 1bcbad3..0000000 --- a/server/src/router/v1/player/account.router.js +++ /dev/null @@ -1,130 +0,0 @@ -// 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 diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index ce972f4..f635b52 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -3,19 +3,19 @@ // // 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`). +// prefix it already owned inside the old monolithic player.routes.js. +// +// Self-service account security (`/player/account/*`) used to be mounted here. It +// is gone: `/auth/me/account/*` is the single canonical self surface for every +// role, and this group's copy was a strictly smaller duplicate of it. // // **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 — and -// both of those live in module-uo now, which changes where they are defined and -// nothing about which URLs answer. +// identical self-scoped handlers under /admin/shard, which lives in module-uo now +// — that changes where they are defined and nothing about which URLs answer. // // See docs/website/API_V2_PLAN.md § Phase 2 for the split. @@ -24,7 +24,6 @@ const express = require('express') const { requireAuth } = require('../../../auth/session.middleware') const noindex = require('../../../middleware/noindex') -const accountRouter = require('./account.router') const appealsRouter = require('./appeals.router') const teamsRouter = require('./teams.router') const teamForumRouter = require('./teamForum.router') @@ -39,7 +38,6 @@ const playerRouter = express.Router() // silently ship without it. playerRouter.use(noindex, requireAuth) -playerRouter.use('/account', accountRouter) playerRouter.use('/appeals', appealsRouter) playerRouter.use('/teams', teamsRouter) // Same prefix, second router. The forum and the leader-exercised grant flow are a diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index d410b34..ee31a2c 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -26,7 +26,7 @@ }, { "name": "Auth · Me", - "description": "The signed-in account: profile, notification streams and devices" + "description": "The signed-in account: profile, account security (credentials, 2FA, linked identities, recovery codes), notification streams and devices" }, { "name": "Auth · Mobile", @@ -40,14 +40,6 @@ "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, - { - "name": "Admin · Account", - "description": "Self-service account security (2FA, linked identities)" - }, - { - "name": "Player", - "description": "Self-service player accounts (register, credentials, 2FA, linked identities)" - }, { "name": "Player · Appeals", "description": "Player-submitted moderation appeals" @@ -155,345 +147,6 @@ } } }, - "/api/v1/admin/account": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Get the current account (self)", - "description": "", - "responses": { - "200": { - "description": "The account", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountStatus" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/identities": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "List linked SSO identities (self)", - "description": "", - "responses": { - "200": { - "description": "Linked identities", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LinkedIdentity" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/identities/{provider}": { - "delete": { - "tags": [ - "Admin · Account" - ], - "summary": "Unlink an SSO identity (self)", - "description": "", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } - ], - "responses": { - "200": { - "description": "Unlinked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnlinkedFlag" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "No linked account for that provider", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/account/totp/disable": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Disable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Not enabled, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/admin/account/totp/enable": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Enable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Setup not started, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/admin/account/totp/setup": { - "post": { - "tags": [ - "Admin · Account" - ], - "summary": "Begin 2FA enrollment (returns secret + QR)", - "description": "", - "responses": { - "200": { - "description": "otpauth URL and QR data to scan", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpSetup" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/admin/activity": { "get": { "tags": [ @@ -9922,500 +9575,6 @@ } } }, - "/api/v1/player/account": { - "get": { - "tags": [ - "Player" - ], - "summary": "Get the current player account (self)", - "description": "", - "responses": { - "200": { - "description": "The player account", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlayerAccount" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/identities": { - "get": { - "tags": [ - "Player" - ], - "summary": "List linked SSO identities (self)", - "description": "", - "responses": { - "200": { - "description": "Linked identities", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LinkedIdentity" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/identities/{provider}": { - "delete": { - "tags": [ - "Player" - ], - "summary": "Unlink an SSO identity (self)", - "description": "", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } - ], - "responses": { - "200": { - "description": "Unlinked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnlinkedFlag" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No linked account for that provider", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/password": { - "patch": { - "tags": [ - "Player" - ], - "summary": "Change or set the current player’s password", - "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.", - "responses": { - "200": { - "description": "Password changed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OkFlag" - } - } - } - }, - "400": { - "description": "Validation error or wrong current password", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many changes (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/disable": { - "post": { - "tags": [ - "Player" - ], - "summary": "Disable 2FA by confirming a code", - "description": "Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.", - "responses": { - "200": { - "description": "2FA disabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Not enabled, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/enable": { - "post": { - "tags": [ - "Player" - ], - "summary": "Enable 2FA by confirming a code", - "description": "", - "responses": { - "200": { - "description": "2FA enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpState" - } - } - } - }, - "400": { - "description": "Setup not started, or invalid code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpCodeRequest" - } - } - } - } - } - }, - "/api/v1/player/account/totp/setup": { - "post": { - "tags": [ - "Player" - ], - "summary": "Begin 2FA enrollment (returns secret + QR)", - "description": "", - "responses": { - "200": { - "description": "otpauth URL and QR data to scan", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpSetup" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Two-factor already enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/account/username": { - "patch": { - "tags": [ - "Player" - ], - "summary": "Change the current player’s username", - "description": "", - "responses": { - "200": { - "description": "Updated username (session cookie re-issued)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Validation error or unavailable username", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not active (disabled/banned)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Username already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many changes (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangeUsernameRequest" - } - } - } - } - } - }, "/api/v1/player/appeals": { "get": { "tags": [ @@ -15993,7 +15152,7 @@ }, "description": { "type": "string", - "example": "Self-service player account (GET /player/account)." + "example": "The signed-in account (GET /auth/me/account). Same shape for every role." }, "properties": { "type": "object", @@ -16034,6 +15193,9 @@ "enum": { "type": "array", "example": [ + "admin", + "editor", + "moderator", "player" ], "items": { @@ -18051,86 +17213,6 @@ } } }, - "AccountStatus": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "object" - }, - "description": { - "type": "string", - "example": "Self-service account security status (GET /admin/account)." - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "integer" - }, - "example": { - "type": "number", - "example": 1 - } - } - }, - "username": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "example": { - "type": "string", - "example": "admin" - } - } - }, - "role": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "string" - }, - "enum": { - "type": "array", - "example": [ - "admin", - "editor" - ], - "items": { - "type": "string" - } - }, - "example": { - "type": "string", - "example": "admin" - } - } - }, - "totp_enabled": { - "type": "object", - "properties": { - "type": { - "type": "string", - "example": "boolean" - }, - "example": { - "type": "boolean", - "example": true - } - } - } - } - } - } - }, "TotpSetup": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index f9c6f71..e0c6f18 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -58,12 +58,10 @@ const doc = { tags: [ { name: 'Health', description: 'Liveness probe' }, { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' }, - { name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' }, + { name: 'Auth · Me', description: 'The signed-in account: profile, account security (credentials, 2FA, linked identities, recovery codes), notification streams and devices' }, { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, - { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, - { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' }, { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, @@ -490,13 +488,16 @@ const doc = { }, }, }, + // The self account as GET /auth/me/account returns it, for EVERY role — the + // name predates the collapse of /player/account and /admin/account onto + // /auth/me and is kept so existing $refs and generated clients resolve. PlayerAccount: { type: 'object', - description: 'Self-service player account (GET /player/account).', + description: 'The signed-in account (GET /auth/me/account). Same shape for every role.', properties: { id: { type: 'integer', example: 42 }, username: { type: 'string', example: 'newplayer' }, - role: { type: 'string', enum: ['player'], example: 'player' }, + role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' }, email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, totp_enabled: { type: 'boolean', example: false }, @@ -749,16 +750,6 @@ const doc = { // the affected resource id/slug or a boolean flag. Documented here as-is so // the spec matches the controllers. (The shapes are intentionally recorded // rather than normalized — see the audit note if standardizing later.) - AccountStatus: { - type: 'object', - description: 'Self-service account security status (GET /admin/account).', - properties: { - id: { type: 'integer', example: 1 }, - username: { type: 'string', example: 'admin' }, - role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, - totp_enabled: { type: 'boolean', example: true }, - }, - }, TotpSetup: { type: 'object', description: 'Enrollment material returned by POST /account/totp/setup.', diff --git a/server/test/mobileDeviceSessions.test.js b/server/test/mobileDeviceSessions.test.js index 773ac51..84b6b15 100644 --- a/server/test/mobileDeviceSessions.test.js +++ b/server/test/mobileDeviceSessions.test.js @@ -8,7 +8,7 @@ process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') -const account = require('../src/router/v1/admin/account.controller') +const account = require('../src/router/v1/auth/account.controller') const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model') const activity = require('../src/model/activity/activity.model') const db = require('../src/utils/db') diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index cef7339..1ad1f78 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -518,8 +518,8 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // is core's limiter FACTORY, not a limiter: a module states its own // window and cap and takes the plumbing, so there is one express-rate-limit in // the process and one place a breach is logged. is - // handed over whole because it is shared policy — core's /auth/me and - // /player/account sit behind the same counter. + // handed over whole because it is shared policy — core's /auth/me/account/* + // and /player/appeals sit behind the same counter. assert.deepEqual(probe.middleware, [ 'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate', ]) diff --git a/server/test/playerAccounts.test.js b/server/test/playerAccounts.test.js index 264753e..763a035 100644 --- a/server/test/playerAccounts.test.js +++ b/server/test/playerAccounts.test.js @@ -8,7 +8,7 @@ const assert = require('node:assert/strict') const bcrypt = require('bcryptjs') const authCtrl = require('../src/router/v1/auth/auth.controller') -const account = require('../src/router/v1/admin/account.controller') +const account = require('../src/router/v1/auth/account.controller') const users = require('../src/model/users/users.model') const settings = require('../src/model/settings/settings.model') const botScore = require('../src/middleware/botScore') diff --git a/server/test/selfTrustedDevices.test.js b/server/test/selfTrustedDevices.test.js index 06176f6..250263e 100644 --- a/server/test/selfTrustedDevices.test.js +++ b/server/test/selfTrustedDevices.test.js @@ -13,7 +13,7 @@ const assert = require('node:assert/strict') // - trusting the current device is ownership-scoped and honors the cap (409); // - self-revoke is scoped to the caller's own id; // - regenerating recovery codes is a password step-up (wrong password → 400). -const ctrl = require('../src/router/v1/admin/account.controller') +const ctrl = require('../src/router/v1/auth/account.controller') const users = require('../src/model/users/users.model') const activity = require('../src/model/activity/activity.model') const sessionService = require('../src/auth/session.service')