diff --git a/server/src/router/v1/admin/account.router.js b/server/src/router/v1/admin/account.router.js new file mode 100644 index 0000000..c674d2f --- /dev/null +++ b/server/src/router/v1/admin/account.router.js @@ -0,0 +1,87 @@ +// 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/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 161fe95..c954820 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -1,3 +1,14 @@ +// Residual /admin routes — the capabilities not yet carved into their own +// router file (docs/website/API_V2_PLAN.md § Phase 2). Mounted at the root of +// /api/v1/admin by admin/index.js, *after* the extracted capability routers and +// behind the shared `noindex, isLoggedIn, staffOnly` gate it owns, so the URLs +// here are unchanged from when this file held all 110 admin routes. +// +// Already extracted: users, account, invites, auth/providers. +// Still here: shard, dashboard, site-mode, posts, uploads, wiki, pages, +// settings, activity, bot-activity, discord-bot, email, moderation, uo-link. +// This file disappears when the last group moves. + const express = require('express') const path = require('path') const fs = require('fs') @@ -6,32 +17,19 @@ const multer = require('multer') const { body, param } = require('express-validator') const ctrl = require('./admin.controller') -const account = require('./account.controller') const botActivity = require('./botActivity.controller') -const authProviders = require('./authProviders.controller') const discordBot = require('./discordBot.controller') const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') const shardOps = require('./shardOps.controller') -const usersShard = require('./usersShard.controller') -const invites = require('./invites.controller') const selfShard = require('../player/shard.controller') const moderation = require('./moderation.controller') const pagesCtrl = require('./pages.controller') -const { isLoggedIn, requireRole } = require('../../../utils/auth') -const noindex = require('../../../middleware/noindex') +const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') const adminRouter = express.Router() -// Every admin route requires auth, a STAFF role, and is kept out of search -// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted -// role: without it, the editor-tier routes below (dashboard, posts, wiki, -// uploads) that are only guarded by isLoggedIn would be reachable by players. -// Players get 403 here and use the self-scoped /player group instead. -const staffOnly = requireRole('admin', 'editor', 'moderator') -adminRouter.use(noindex, isLoggedIn, staffOnly) - // Admin-only gate. Editors may manage content (posts/wiki), but user // management, site mode, and settings are restricted to the admin role. const adminOnly = requireRole('admin') @@ -41,79 +39,6 @@ const adminOnly = requireRole('admin') // admin check inside the controller. const modAccess = requireRole('admin', 'moderator') -// ── Account security (self-service, any logged-in role) ─────────────── -// Not behind adminOnly: an editor manages their own 2FA too. -adminRouter.get( - '/account', - // #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, -) -adminRouter.post( - '/account/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, -) -adminRouter.post( - '/account/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, -) -adminRouter.post( - '/account/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). -adminRouter.get( - '/account/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, -) -adminRouter.delete( - '/account/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, -) - // ── Game account linking (self-service, any staff role) ─────────────── // Staff link their OWN in-game account here, exactly like players do under // /player/shard. The controller keys off req.user.id, so the same handlers work. @@ -979,89 +904,6 @@ adminRouter.post( emailConfig.disconnect, ) -// ── Authentication providers / SSO (admin only) ─────────────────────── -adminRouter.get( - '/auth/providers', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'List configured SSO providers (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - authProviders.list, -) -adminRouter.post( - '/auth/providers', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Create a custom SSO provider (admin only)' - // #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - body('id').matches(/^[a-z0-9-]+$/), - body('kind').isIn(['oidc', 'oauth2']), - body('name').isString().trim().notEmpty().isLength({ max: 80 }), - body('enabled').optional().isBoolean(), - body('clientId').optional({ values: 'falsy' }).isString(), - body('secret').optional({ values: 'falsy' }).isString(), - body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), - body('priority').optional().isInt(), - validate, - authProviders.create, -) -adminRouter.put( - '/auth/providers/:id', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Update an SSO provider (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').matches(/^[a-z0-9-]+$/), - body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }), - body('enabled').optional().isBoolean(), - body('clientId').optional({ values: 'falsy' }).isString(), - body('secret').optional({ values: 'falsy' }).isString(), - body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), - body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), - body('priority').optional().isInt(), - validate, - authProviders.update, -) -adminRouter.delete( - '/auth/providers/:id', - // #swagger.tags = ['Admin · Auth Providers'] - // #swagger.summary = 'Delete a custom SSO provider (admin only)' - // #swagger.description = 'Built-in providers cannot be deleted — disable them instead.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } - /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */ - /* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').matches(/^[a-z0-9-]+$/), - validate, - authProviders.remove, -) - // ── Moderation dashboard (admin + moderator) ────────────────────────── // Read-only views over the bot's mod_actions log, plus staff notes. The whole // sub-path is gated for the moderator role (admins included). @@ -1214,270 +1056,6 @@ adminRouter.get( moderation.getUserAppeals, ) -// ── User management (admin only) ────────────────────────────────────── -adminRouter.use('/users', adminOnly) -adminRouter.get( - '/users', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'List users (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - ctrl.listUsers, -) -adminRouter.post( - '/users', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Create a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ - /* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - body('username').isString().trim().isLength({ min: 3, max: 32 }), - body('password').isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), - body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), - body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), - validate, - ctrl.createUser, -) -adminRouter.put( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Update a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), - body('password').optional().isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), - body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), - body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }), - validate, - ctrl.updateUser, -) -adminRouter.delete( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Delete a user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ - /* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.deleteUser, -) - -// ── A user's trusted devices & MFA (admin only) ─────────────────────── -adminRouter.get( - '/users/:id/trusted-devices', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'List a user’s trusted devices (admin only)' - // #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.listUserTrustedDevices, -) -adminRouter.delete( - '/users/:id/trusted-devices', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.revokeAllUserTrustedDevices, -) -adminRouter.delete( - '/users/:id/trusted-devices/:deviceId', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - // #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' } - /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - param('deviceId').isInt({ min: 1 }), - validate, - ctrl.revokeUserTrustedDevice, -) -adminRouter.post( - '/users/:id/mfa/reset', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Reset a user’s MFA (admin only)' - // #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ - /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - ctrl.resetUserMfa, -) - -// ── User → shard (uo-link) footprint (admin only) ───────────────────── -// Backs the /admin/users/:id detail page: a user's linked game accounts and, -// scoped to those accounts, their vendor sales / houses / online characters. -// Live character rosters are fetched by the client through /admin/shard/* (which -// already grants admins a bypass to any account), so no routes for them here. -adminRouter.get( - '/users/:id', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Get a single user (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getUser, -) -adminRouter.get( - '/users/:id/shard/accounts', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s linked game accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.listAccounts, -) -adminRouter.get( - '/users/:id/shard/sales', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getSales, -) -adminRouter.get( - '/users/:id/shard/houses', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getHouses, -) -adminRouter.get( - '/users/:id/shard/online', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s characters currently online (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getOnline, -) -adminRouter.get( - '/users/:id/shard/standing', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - param('id').isInt(), - validate, - usersShard.getStanding, -) -adminRouter.delete( - '/users/:id/shard/link/:account', - // #swagger.tags = ['Admin · Users'] - // #swagger.summary = 'Unlink a game account from this user (admin only)' - // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } - /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ - /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').isInt(), - param('account').matches(SHARD_ACCOUNT_RE), - validate, - usersShard.unlinkAccount, -) - -// ── Email invites (admin only) ───────────────────────────────────────────── -adminRouter.post( - '/invites', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'Create and email an account invite at a chosen access level' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ - /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ - adminOnly, - body('email').isEmail().isLength({ max: 255 }), - body('role').isIn(['admin', 'editor', 'moderator', 'player']), - validate, - invites.create, -) -adminRouter.get( - '/invites', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'List recent invites (no tokens)' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ - adminOnly, - invites.list, -) -adminRouter.delete( - '/invites/:id', - // #swagger.tags = ['Admin · Invites'] - // #swagger.summary = 'Revoke a pending invite' - // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' } - /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ - /* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ - adminOnly, - param('id').isInt(), - validate, - invites.revoke, -) - // ── uo-link sidecar control (admin only) ────────────────────────────────── // Connection config (base/ws URL + token + protocol + enabled) and the town // crier. The token is write-only (SECURITY note in uoLink.controller.js). diff --git a/server/src/router/v1/admin/authProviders.router.js b/server/src/router/v1/admin/authProviders.router.js new file mode 100644 index 0000000..001f615 --- /dev/null +++ b/server/src/router/v1/admin/authProviders.router.js @@ -0,0 +1,102 @@ +// Admin · Auth Providers — SSO/OAuth2/OIDC provider configuration. +// +// Mounted at /api/v1/admin/auth by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`; the routes below are /providers under that, +// so the emitted URLs stay /api/v1/admin/auth/providers[/:id]. +// +// Admin-only: these rows carry client secrets (write-only, AES-GCM at rest via +// utils/secretBox.js) and decide which external identities may sign in at all. + +const express = require('express') +const { body, param } = require('express-validator') + +const authProviders = require('./authProviders.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +const providersRouter = express.Router() +const adminOnly = requireRole('admin') + +providersRouter.get( + '/providers', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'List configured SSO providers (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + authProviders.list, +) +providersRouter.post( + '/providers', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Create a custom SSO provider (admin only)' + // #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('id').matches(/^[a-z0-9-]+$/), + body('kind').isIn(['oidc', 'oauth2']), + body('name').isString().trim().notEmpty().isLength({ max: 80 }), + body('enabled').optional().isBoolean(), + body('clientId').optional({ values: 'falsy' }).isString(), + body('secret').optional({ values: 'falsy' }).isString(), + body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), + body('priority').optional().isInt(), + validate, + authProviders.create, +) +providersRouter.put( + '/providers/:id', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Update an SSO provider (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').matches(/^[a-z0-9-]+$/), + body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }), + body('enabled').optional().isBoolean(), + body('clientId').optional({ values: 'falsy' }).isString(), + body('secret').optional({ values: 'falsy' }).isString(), + body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }), + body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }), + body('priority').optional().isInt(), + validate, + authProviders.update, +) +providersRouter.delete( + '/providers/:id', + // #swagger.tags = ['Admin · Auth Providers'] + // #swagger.summary = 'Delete a custom SSO provider (admin only)' + // #swagger.description = 'Built-in providers cannot be deleted — disable them instead.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' } + /* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */ + /* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').matches(/^[a-z0-9-]+$/), + validate, + authProviders.remove, +) + +module.exports = providersRouter diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js new file mode 100644 index 0000000..5887d88 --- /dev/null +++ b/server/src/router/v1/admin/index.js @@ -0,0 +1,46 @@ +// /api/v1/admin — the admin surface, assembled from per-capability routers. +// +// This file owns exactly two things: the gate every admin 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 admin.routes.js, so the +// emitted URL set is byte-identical — proved per PR by a zero-line diff in +// server/routes.manifest.json (`npm run routes:manifest`). +// +// See docs/website/API_V2_PLAN.md § Phase 2 for the split and its remaining PRs. + +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') +const residualRouter = require('./admin.routes') + +const adminRouter = express.Router() + +// Every admin route requires auth, a STAFF role, and is kept out of search +// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted +// role: without it, the editor-tier routes below (dashboard, posts, wiki, +// uploads) that are only guarded by isLoggedIn would be reachable by players. +// Players get 403 here and use the self-scoped /player group instead. +// +// It lives here, ahead of every mount, so a capability router extracted in a +// later PR cannot silently ship without it. +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 +// routes inside read as /providers[/:id]. +adminRouter.use('/auth', authProvidersRouter) + +// Everything not yet extracted, at the group root. Mounted last, but none of the +// prefixes above appear in it, so nothing here depends on the ordering. +adminRouter.use('/', residualRouter) + +module.exports = adminRouter diff --git a/server/src/router/v1/admin/invites.router.js b/server/src/router/v1/admin/invites.router.js new file mode 100644 index 0000000..d6c12da --- /dev/null +++ b/server/src/router/v1/admin/invites.router.js @@ -0,0 +1,54 @@ +// Admin · Invites — create, list and revoke emailed account invites. +// +// Mounted at /api/v1/admin/invites by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Issuing an invite picks the new account's +// role, so it is admin-only — otherwise an editor could mint an admin. + +const express = require('express') +const { body, param } = require('express-validator') + +const invites = require('./invites.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +const invitesRouter = express.Router() +const adminOnly = requireRole('admin') + +invitesRouter.post( + '/', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Create and email an account invite at a chosen access level' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ + /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + adminOnly, + body('email').isEmail().isLength({ max: 255 }), + body('role').isIn(['admin', 'editor', 'moderator', 'player']), + validate, + invites.create, +) +invitesRouter.get( + '/', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'List recent invites (no tokens)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + adminOnly, + invites.list, +) +invitesRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Revoke a pending invite' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' } + /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + validate, + invites.revoke, +) + +module.exports = invitesRouter diff --git a/server/src/router/v1/admin/users.router.js b/server/src/router/v1/admin/users.router.js new file mode 100644 index 0000000..5fc7700 --- /dev/null +++ b/server/src/router/v1/admin/users.router.js @@ -0,0 +1,249 @@ +// Admin · Users — user management, MFA recovery, and a user's shard footprint. +// +// Mounted at /api/v1/admin/users by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors +// and moderators manage content and reports, never accounts. +// +// Handlers still live in admin.controller.js (users) and usersShard.controller.js +// (uo-link footprint); this PR re-wires routes, not logic. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./admin.controller') +const usersShard = require('./usersShard.controller') +const { requireRole } = require('../../../utils/auth') +const validate = require('../../../middleware/validate') + +// Same shape the shard routes validate account names with. +const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/ + +const usersRouter = express.Router() +const adminOnly = requireRole('admin') + +usersRouter.use(adminOnly) +usersRouter.get( + '/', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'List users (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listUsers, +) +usersRouter.post( + '/', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Create a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ + /* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + body('username').isString().trim().isLength({ min: 3, max: 32 }), + body('password').isString().isLength({ min: 8, max: 64 }), + body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), + body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), + body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }), + validate, + ctrl.createUser, +) +usersRouter.put( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Update a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), + body('password').optional().isString().isLength({ min: 8, max: 64 }), + body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']), + body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']), + body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }), + validate, + ctrl.updateUser, +) +usersRouter.delete( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Delete a user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */ + /* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.deleteUser, +) + +// ── A user's trusted devices & MFA (admin only) ─────────────────────── +usersRouter.get( + '/:id/trusted-devices', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'List a user’s trusted devices (admin only)' + // #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.listUserTrustedDevices, +) +usersRouter.delete( + '/:id/trusted-devices', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.revokeAllUserTrustedDevices, +) +usersRouter.delete( + '/:id/trusted-devices/:deviceId', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' } + /* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + param('deviceId').isInt({ min: 1 }), + validate, + ctrl.revokeUserTrustedDevice, +) +usersRouter.post( + '/:id/mfa/reset', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Reset a user’s MFA (admin only)' + // #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + ctrl.resetUserMfa, +) + +// ── User → shard (uo-link) footprint (admin only) ───────────────────── +// Backs the /admin/users/:id detail page: a user's linked game accounts and, +// scoped to those accounts, their vendor sales / houses / online characters. +// Live character rosters are fetched by the client through /admin/shard/* (which +// already grants admins a bypass to any account), so no routes for them here. +usersRouter.get( + '/:id', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Get a single user (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getUser, +) +usersRouter.get( + '/:id/shard/accounts', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s linked game accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.listAccounts, +) +usersRouter.get( + '/:id/shard/sales', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getSales, +) +usersRouter.get( + '/:id/shard/houses', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Houses owned by a user’s accounts (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getHouses, +) +usersRouter.get( + '/:id/shard/online', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s characters currently online (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getOnline, +) +usersRouter.get( + '/:id/shard/standing', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getStanding, +) +usersRouter.delete( + '/:id/shard/link/:account', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Unlink a game account from this user (admin only)' + // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ + /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + param('account').matches(SHARD_ACCOUNT_RE), + validate, + usersShard.unlinkAccount, +) + +module.exports = usersRouter diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index 4f9edf8..98e30dc 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -4,7 +4,7 @@ const v1Router = express.Router() const authRouter = require('./auth/auth.routes') const publicRouter = require('./public/public.routes') -const adminRouter = require('./admin/admin.routes') +const adminRouter = require('./admin') const playerRouter = require('./player/player.routes') v1Router.use('/auth', authRouter) diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 896c836..9e08dd0 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -98,6 +98,24 @@ } ], "paths": { + "/api/csp-report": { + "post": { + "tags": [ + "Health" + ], + "summary": "Content-Security-Policy violation report sink", + "description": "Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.", + "responses": { + "204": { + "description": "Report accepted (or ignored). No content." + }, + "429": { + "description": "Too many reports from this address." + } + }, + "security": [] + } + }, "/api/health": { "get": { "tags": [ @@ -125,3332 +143,6 @@ } } }, - "/api/csp-report": { - "post": { - "tags": [ - "Health" - ], - "summary": "Content-Security-Policy violation report sink", - "description": "Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.", - "responses": { - "204": { - "description": "Report accepted (or ignored). No content." - }, - "429": { - "description": "Too many reports from this address." - } - }, - "security": [] - } - }, - "/api/v1/auth/login": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Log in with username and password", - "description": "On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.", - "responses": { - "200": { - "description": "Session issued, or TOTP challenge required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Incorrect username or password", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "429": { - "description": "Too many attempts (rate limited / backoff)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - } - } - } - }, - "/api/v1/auth/register": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Register a player account", - "description": "Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.", - "responses": { - "200": { - "description": "Account created and session issued", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "400": { - "description": "Validation error or unavailable username", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "403": { - "description": "Registration is not open", - "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 attempts (rate limited / backoff)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } - } - } - } - } - }, - "/api/v1/auth/login/totp": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Complete login with a TOTP or recovery code", - "description": "Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.", - "responses": { - "200": { - "description": "Session issued (optionally with a trusted-device-limit prompt)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Invalid code or expired challenge", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many attempts (rate limited / backoff)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TotpLoginRequest" - } - } - } - } - } - }, - "/api/v1/auth/invite/{token}": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Look up an email invite by token", - "description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.", - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Invite details", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Invalid or expired invite", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/auth/invite/{token}/accept": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Accept an email invite (creates the account at the invited role)", - "description": "Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.", - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Account created and session issued", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "404": { - "description": "Invalid or expired invite", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Username taken or invite already used", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": {} - } - }, - "/api/v1/auth/password/forgot": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Request a password-reset link by email", - "description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.", - "responses": { - "200": { - "description": "Generic acknowledgement (sent if the account exists)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "429": { - "description": "Too many requests", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "format": "email" - } - } - } - } - } - } - } - }, - "/api/v1/auth/password/reset/{token}": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Validate a password-reset link", - "description": "Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Reset link is valid", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Invalid or expired reset link", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - }, - "post": { - "tags": [ - "Auth" - ], - "summary": "Set a new password from a reset link", - "description": "Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.", - "parameters": [ - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Password changed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "404": { - "description": "Invalid, expired, or already-used reset link", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many attempts", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "password" - ], - "properties": { - "password": { - "type": "string", - "minLength": 8, - "maxLength": 64 - } - } - } - } - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": [ - "Auth" - ], - "summary": "Log out (clear the cookie and revoke this session)", - "description": "", - "responses": { - "200": { - "description": "Logged out", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - } - } - } - }, - "/api/v1/auth/me": { - "get": { - "tags": [ - "Auth" - ], - "summary": "Current authenticated user", - "description": "", - "responses": { - "200": { - "description": "The signed-in user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/mobile/login": { - "post": { - "tags": [ - "Auth · Mobile" - ], - "summary": "Native login → access + refresh tokens", - "description": "Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.", - "responses": { - "200": { - "description": "Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileTokenResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Invalid credentials, or a TOTP code is required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many attempts (rate limited / backoff)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileLoginRequest" - } - } - } - } - } - }, - "/api/v1/auth/mobile/refresh": { - "post": { - "tags": [ - "Auth · Mobile" - ], - "summary": "Rotate a refresh token for a fresh token pair", - "description": "Refresh tokens are single-use: the presented token is revoked and a new access + refresh pair is issued. Reusing a rotated token fails with 401.", - "responses": { - "200": { - "description": "New access + refresh tokens", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileTokenResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Invalid or expired session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many refresh attempts", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileRefreshRequest" - } - } - } - } - } - }, - "/api/v1/auth/mobile/logout": { - "post": { - "tags": [ - "Auth · Mobile" - ], - "summary": "Revoke the current (or all) refresh tokens", - "description": "Requires a valid bearer access token. Revokes the given refresh token, or every session for the user when { all: true }. Idempotent.", - "responses": { - "200": { - "description": "Logged out", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Missing or invalid bearer token", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileLogoutRequest" - } - } - } - } - } - }, - "/api/v1/auth/mobile/sso/start": { - "get": { - "tags": [ - "Auth · Mobile" - ], - "summary": "Begin native SSO login (redirect to the IdP)", - "description": "Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.", - "parameters": [ - { - "name": "provider", - "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id from GET /auth/providers (e.g. google, discord)." - }, - { - "name": "code_challenge", - "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "description": "App-generated PKCE S256 challenge (base64url)." - }, - { - "name": "state", - "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "description": "App-generated opaque CSRF value, echoed on the callback for the app to verify." - }, - { - "name": "redirect_uri", - "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)." - } - ], - "responses": { - "302": { - "description": "Redirect to the identity provider (or back to the app callback on error)" - }, - "400": { - "description": "Unrecognized redirect URI or validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many attempts (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/v1/auth/mobile/sso/exchange": { - "post": { - "tags": [ - "Auth · Mobile" - ], - "summary": "Exchange an SSO authorization code for mobile tokens", - "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.", - "responses": { - "200": { - "description": "Access + refresh tokens", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileTokenResponse" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Invalid/expired/used code or failed PKCE verification", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Too many attempts (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileSsoExchangeRequest" - } - } - } - } - } - }, - "/api/v1/auth/providers": { - "get": { - "tags": [ - "Auth · SSO" - ], - "summary": "List enabled SSO providers", - "description": "Public discovery used by the login page to render provider buttons. Never exposes secrets.", - "responses": { - "200": { - "description": "Enabled, valid providers", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/auth/sso/{provider}/start": { - "get": { - "tags": [ - "Auth · SSO" - ], - "summary": "Begin SSO login (redirect to the IdP)", - "description": "Sets a short-lived signed transaction cookie and 302-redirects to the provider authorize URL. On error redirects back to the login page with an sso_error query param.", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id (e.g. google, discord)." - }, - { - "name": "returnTo", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Internal /admin path to return to after login." - } - ], - "responses": { - "302": { - "description": "Redirect to the identity provider (or back to the login page on error)" - } - } - } - }, - "/api/v1/auth/sso/{provider}/link": { - "get": { - "tags": [ - "Auth · SSO" - ], - "summary": "Begin linking an SSO identity to the current account", - "description": "Requires an authenticated session; the signed transaction captures the acting user so the callback can attach the external identity.", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id (e.g. google, discord)." - } - ], - "responses": { - "302": { - "description": "Redirect to the identity provider (or back to the account page on error)" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/sso/{provider}/callback": { - "get": { - "tags": [ - "Auth · SSO" - ], - "summary": "OAuth redirect target — completes login or linking", - "description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id (e.g. google, discord)." - }, - { - "name": "error", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "code", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "OAuth authorization code." - }, - { - "name": "state", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "OAuth state (matched against the tx cookie)." - } - ], - "responses": { - "302": { - "description": "Redirect into /admin on success, or back to login/account with an error code" - } - } - } - }, - "/api/v1/auth/sso/totp": { - "post": { - "tags": [ - "Auth · SSO" - ], - "summary": "Complete an SSO login with a TOTP code", - "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.", - "responses": { - "200": { - "description": "Session issued", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/SafeUser" - }, - "returnTo": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Invalid code or expired challenge", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Conflict" - }, - "429": { - "description": "Too many attempts (rate limited / backoff)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "code" - ], - "properties": { - "code": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/v1/auth/me/account": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "Get the current account (self, any role)", - "description": "", - "responses": { - "200": { - "description": "The current 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", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/account/username": { - "patch": { - "tags": [ - "Auth · Me" - ], - "summary": "Change the current account’s username (self, any role)", - "description": "", - "responses": { - "200": { - "description": "Updated username (session 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": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "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/auth/me/account/password": { - "patch": { - "tags": [ - "Auth · Me" - ], - "summary": "Change or set the current account’s password (self, any role)", - "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 own session is re-issued (they stay logged in) while older web 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": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "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/auth/me/account/totp/setup": { - "post": { - "tags": [ - "Auth · Me" - ], - "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" - } - } - } - }, - "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/auth/me/account/totp/enable": { - "post": { - "tags": [ - "Auth · Me" - ], - "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/auth/me/account/totp/disable": { - "post": { - "tags": [ - "Auth · Me" - ], - "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/auth/me/account/identities": { - "get": { - "tags": [ - "Auth · Me" - ], - "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/auth/me/account/identities/{provider}": { - "delete": { - "tags": [ - "Auth · Me" - ], - "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/auth/me/sessions": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "List active mobile device sessions (self)", - "description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.", - "responses": { - "200": { - "description": "Active device sessions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DeviceSession" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/sessions/{id}": { - "delete": { - "tags": [ - "Auth · Me" - ], - "summary": "Revoke one mobile device session (self)", - "description": "Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "The session row id from GET /auth/me/sessions." - } - ], - "responses": { - "200": { - "description": "Revoked (idempotent)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "revoked": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/trusted-devices": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "List trusted devices (self)", - "description": "Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.", - "responses": { - "200": { - "description": "Active trusted devices", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrustedDevice" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Auth · Me" - ], - "summary": "Trust the current device (self)", - "description": "Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native to store. Returns 409 { error: \"trusted_device_limit\", devices } when the per-user cap is reached — revoke one first, then retry.", - "responses": { - "200": { - "description": "Device trusted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrustDeviceResult" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "409": { - "description": "Trusted-device limit reached", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TrustedDeviceLimit" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "deviceName": { - "type": "string" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "Auth · Me" - ], - "summary": "Revoke all trusted devices (self)", - "description": "Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.", - "responses": { - "200": { - "description": "Revoked count", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "revoked": { - "type": "integer" - } - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/trusted-devices/{id}": { - "delete": { - "tags": [ - "Auth · Me" - ], - "summary": "Revoke one trusted device (self)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Trusted-device id from GET /auth/me/trusted-devices." - } - ], - "responses": { - "200": { - "description": "Revoked (idempotent)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "revoked": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/account/recovery-codes/status": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "Remaining recovery-code count (self)", - "description": "", - "responses": { - "200": { - "description": "Remaining unused codes", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "remaining": { - "type": "integer" - } - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/account/recovery-codes/generate": { - "post": { - "tags": [ - "Auth · Me" - ], - "summary": "Regenerate recovery codes (self, password step-up)", - "description": "Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.", - "responses": { - "200": { - "description": "New recovery codes (shown once)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecoveryCodes" - } - } - } - }, - "400": { - "description": "Wrong password, or two-factor not enabled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "429": { - "description": "Too Many Requests" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "currentPassword": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/v1/auth/me/devices": { - "post": { - "tags": [ - "Auth · Me" - ], - "summary": "Register a push device (endpoint) for the current user", - "description": "Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).", - "responses": { - "201": { - "description": "Device registered", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PushDevice" - } - } - } - }, - "400": { - "description": "Validation error or disallowed endpoint", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterDeviceRequest" - } - } - } - } - }, - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "List the current user’s registered push devices", - "description": "", - "responses": { - "200": { - "description": "Registered devices", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PushDevice" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/devices/{id}": { - "delete": { - "tags": [ - "Auth · Me" - ], - "summary": "Unregister a push device", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Device id (must belong to the caller)." - } - ], - "responses": { - "200": { - "description": "Unregistered", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OkFlag" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "404": { - "description": "No such device for this user", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/notifications/streams": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "List subscribable notification streams (catalog)", - "description": "The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.", - "responses": { - "200": { - "description": "Stream catalog", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationStreams" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/auth/me/notifications/subscriptions": { - "get": { - "tags": [ - "Auth · Me" - ], - "summary": "Get the current user’s notification subscriptions", - "description": "", - "responses": { - "200": { - "description": "Subscribed stream ids", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationSubscriptions" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Auth · Me" - ], - "summary": "Replace the current user’s notification subscriptions", - "description": "Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.", - "responses": { - "200": { - "description": "Updated subscriptions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationSubscriptions" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationSubscriptions" - } - } - } - } - } - }, - "/api/v1/public/settings": { - "get": { - "tags": [ - "Public" - ], - "summary": "Public site settings + branding", - "description": "Whitelisted, non-sensitive settings plus the per-shard brand block (name/colors/logo/hero/favicon) a client themes itself from, and derived registration / game-account-signup availability flags.", - "responses": { - "200": { - "description": "Public settings + branding", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicSettings" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/status": { - "get": { - "tags": [ - "Public" - ], - "summary": "Site mode / status", - "description": "Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.", - "responses": { - "200": { - "description": "Site status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicStatus" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/version": { - "get": { - "tags": [ - "Public" - ], - "summary": "Backend identity + version", - "description": "Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.", - "responses": { - "200": { - "description": "Backend version", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicVersion" - } - } - } - } - } - } - }, - "/api/v1/public/contact": { - "post": { - "tags": [ - "Public" - ], - "summary": "Send a contact message", - "description": "Emails the site owner (or falls back to a mailto). Rate limited.", - "responses": { - "200": { - "description": "Message sent", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "429": { - "description": "Too many messages (rate limited)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "502": { - "description": "Mail delivery failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContactRequest" - } - } - } - } - } - }, - "/api/v1/public/posts/{category}": { - "get": { - "tags": [ - "Public" - ], - "summary": "List published posts in a category", - "description": "Gated by site mode: during maintenance only admins with a valid session see content.", - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "news | five-on-friday | newsletter | screenshots" - } - ], - "responses": { - "200": { - "description": "Published posts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Post" - } - } - } - } - }, - "404": { - "description": "Unknown category", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/posts/{category}/{idOrSlug}": { - "get": { - "tags": [ - "Public" - ], - "summary": "Get a single published post", - "description": "", - "parameters": [ - { - "name": "category", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Post category." - }, - { - "name": "idOrSlug", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Numeric id or slug." - } - ], - "responses": { - "200": { - "description": "The post", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Post" - } - } - } - }, - "404": { - "description": "Unknown category or post not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/wiki": { - "get": { - "tags": [ - "Public" - ], - "summary": "List published wiki pages", - "description": "", - "parameters": [ - { - "name": "q", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "category", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "tag", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Published wiki pages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WikiPage" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/wiki/categories": { - "get": { - "tags": [ - "Public" - ], - "summary": "List wiki categories", - "description": "", - "responses": { - "200": { - "description": "Wiki categories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WikiCategory" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/wiki/tags": { - "get": { - "tags": [ - "Public" - ], - "summary": "List wiki tags", - "description": "", - "responses": { - "200": { - "description": "Wiki tags", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/wiki/{slug}": { - "get": { - "tags": [ - "Public" - ], - "summary": "Get a single published wiki page", - "description": "", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Wiki page slug." - } - ], - "responses": { - "200": { - "description": "The wiki page", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WikiPage" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/pages/{id}/preview/{token}": { - "get": { - "tags": [ - "Public" - ], - "summary": "Render a page from a draft-preview token", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Page id." - }, - { - "name": "token", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Preview token from POST /admin/pages/:id/preview." - } - ], - "responses": { - "200": { - "description": "The page (any status)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "404": { - "description": "Token invalid/expired or page missing", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/pages/{slug}": { - "get": { - "tags": [ - "Public" - ], - "summary": "Get a published CMS page by slug", - "description": "Drafts 404 for the public; staff sessions see drafts. Gated by site mode.", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Page slug." - } - ], - "responses": { - "200": { - "description": "The page", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Service Unavailable" - } - } - } - }, - "/api/v1/public/shard/status": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Shard connection state, online count and latest economy", - "description": "", - "responses": { - "200": { - "description": "Shard status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardStatus" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/feed": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Recent notable shard events (from the ingested log)", - "description": "", - "parameters": [ - { - "name": "kind", - "in": "query", - "required": false, - "schema": { - "type": "string" - }, - "description": "Filter to a single event kind, e.g. vendor.sale." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max rows (default 100, max 1000)." - } - ], - "responses": { - "200": { - "description": "Events, newest first", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEvent" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/economy": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Gold-supply time series (oldest → newest)", - "description": "", - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max samples (default 100, max 1000)." - } - ], - "responses": { - "200": { - "description": "Economy samples", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEconomyPoint" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/online": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Staff online now (linked staff accounts; location is admin/moderator-only)", - "description": "", - "responses": { - "200": { - "description": "Online players", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardOnlinePlayer" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/idoc": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Houses currently in danger (IDOC)", - "description": "", - "responses": { - "200": { - "description": "IDOC houses", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/champs": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current champion-spawn board (all categories)", - "description": "The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.", - "responses": { - "200": { - "description": "Champion spawns, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/guilds": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current guild board (rosters, alliances, leaders)", - "description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.", - "responses": { - "200": { - "description": "Guilds, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/governors": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Current town-governor board (City Loyalty)", - "description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.", - "responses": { - "200": { - "description": "Cities, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/governors/{city}/history": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Governor term history for a city", - "description": "", - "parameters": [ - { - "name": "city", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "City name, e.g. Britain." - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max terms (default 100, max 500)." - } - ], - "responses": { - "200": { - "description": "Terms, newest first", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/presence": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Online population aggregate (count + per-facet + per-region)", - "description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.", - "responses": { - "200": { - "description": "Population snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/houses": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "House registry (owner, co-owners, price, decay)", - "description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.", - "responses": { - "200": { - "description": "Houses, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - } - } - }, - "/api/v1/public/shard/stream": { - "get": { - "tags": [ - "Public · Shard" - ], - "summary": "Live shard event stream (Server-Sent Events, public/safe kinds)", - "description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.", - "responses": { - "200": { - "description": "An SSE stream (Content-Type: text/event-stream)." - } - } - } - }, "/api/v1/admin/account": { "get": { "tags": [ @@ -3493,192 +185,6 @@ ] } }, - "/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/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/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/identities": { "get": { "tags": [ @@ -3790,26 +296,36 @@ ] } }, - "/api/v1/admin/shard/link": { + "/api/v1/admin/account/totp/disable": { "post": { "tags": [ "Admin · Account" ], - "summary": "Link an in-game account with a one-time code (self)", + "summary": "Disable 2FA by confirming a code", "description": "", "responses": { "200": { - "description": "Linked", + "description": "2FA disabled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ShardLinkResult" + "$ref": "#/components/schemas/TotpState" } } } }, "400": { - "description": "Unknown or expired code", + "description": "Not enabled, or invalid code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", "content": { "application/json": { "schema": { @@ -3820,12 +336,6 @@ }, "500": { "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" } }, "security": [ @@ -3841,281 +351,43 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ShardLinkRequest" + "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, - "/api/v1/admin/shard/accounts": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "List the caller’s linked game accounts (self)", - "description": "", - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/roster/{account}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Character roster for an account (self; admins: any account)", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Account roster", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/vendors/{account}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Player vendors for an account (self; admins: any account)", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Vendor snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/char/{serial}": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Character sheet (self-linked characters; admins: any character)", - "description": "", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Mobile serial, e.g. 0x24C." - } - ], - "responses": { - "200": { - "description": "Character profile", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Character not on an account linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/sales": { - "get": { - "tags": [ - "Admin · Account" - ], - "summary": "Recent player-vendor sales for the caller’s linked accounts (self)", - "description": "", - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/shard/account": { + "/api/v1/admin/account/totp/enable": { "post": { "tags": [ "Admin · Account" ], - "summary": "Create a game account and link it to the caller (staff self-service)", - "description": "Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.", + "summary": "Enable 2FA by confirming a code", + "description": "", "responses": { - "201": { - "description": "Account created and linked", + "200": { + "description": "2FA enabled", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/TotpState" } } } }, "400": { - "description": "Bad Request" + "description": "Setup not started, or invalid code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "403": { - "description": "Game-account signup unavailable (site or shard)", + "401": { + "description": "Not authenticated", "content": { "application/json": { "schema": { @@ -4125,54 +397,7 @@ } }, "409": { - "description": "Account name already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": {} - } - }, - "/api/v1/admin/shard/kick": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Kick every live session of an account (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Kicked", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Protected target or write plane disabled", + "description": "Two-factor already enabled", "content": { "application/json": { "schema": { @@ -4198,45 +423,43 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "serial": { - "type": "string" - } - } + "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, - "/api/v1/admin/shard/ban": { + "/api/v1/admin/account/totp/setup": { "post": { "tags": [ - "Admin · Shard" + "Admin · Account" ], - "summary": "Ban an account, timed or indefinite (admin/moderator)", + "summary": "Begin 2FA enrollment (returns secret + QR)", "description": "", "responses": { "200": { - "description": "Banned", + "description": "otpauth URL and QR data to scan", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/TotpSetup" } } } }, - "400": { - "description": "Bad Request" + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "403": { - "description": "Protected target or write plane disabled", + "409": { + "description": "Two-factor already enabled", "content": { "application/json": { "schema": { @@ -4256,154 +479,37 @@ { "bearerAuth": [] } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "serial": { - "type": "string" - }, - "durationSec": { - "type": "integer" - }, - "reason": { - "type": "string" - } - } - } - } - } - } + ] } }, - "/api/v1/admin/shard/unban": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Clear an account ban (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Unbanned", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - } - }, - "required": [ - "account" - ] - } - } - } - } - } - }, - "/api/v1/admin/shard/broadcast": { - "post": { - "tags": [ - "Admin · Shard" - ], - "summary": "Broadcast a system message to everyone online (admin/moderator)", - "description": "", - "responses": { - "200": { - "description": "Broadcast", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "hue": { - "type": "integer" - } - }, - "required": [ - "text" - ] - } - } - } - } - } - }, - "/api/v1/admin/shard/pages": { + "/api/v1/admin/activity": { "get": { "tags": [ - "Admin · Shard" + "Admin · Activity" ], - "summary": "Open help-page (support) queue (admin/moderator)", + "summary": "List recent admin activity", "description": "", + "parameters": [ + { + "name": "offset", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max rows to return." + } + ], "responses": { "200": { - "description": "Open pages", + "description": "Activity entries", "content": { "application/json": { "schema": { @@ -4416,6 +522,16 @@ } } }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "500": { "description": "Internal Server Error" } @@ -4430,41 +546,109 @@ ] } }, - "/api/v1/admin/shard/pages/{id}/respond": { - "post": { + "/api/v1/admin/auth/providers": { + "get": { "tags": [ - "Admin · Shard" + "Admin · Auth Providers" ], - "summary": "Reply to a help page, optionally closing it (admin/moderator)", + "summary": "List configured SSO providers (admin only)", "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Page id (sender serial)." - } - ], "responses": { "200": { - "description": "Responded", + "description": "Providers (secrets stripped)", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderConfig" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Auth Providers" + ], + "summary": "Create a custom SSO provider (admin only)", + "description": "Built-in providers (google, discord) are configured via PUT, not created here.", + "responses": { + "201": { + "description": "Created provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderConfig" } } } }, "400": { - "description": "Bad Request" + "description": "Validation error, or a built-in/invalid kind", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "404": { - "description": "Unknown page", + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Provider id already exists", "content": { "application/json": { "schema": { @@ -4490,30 +674,19 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "close": { - "type": "boolean" - } - }, - "required": [ - "message" - ] + "$ref": "#/components/schemas/ProviderCreateRequest" } } } } } }, - "/api/v1/admin/shard/pages/{id}/close": { - "post": { + "/api/v1/admin/auth/providers/{id}": { + "put": { "tags": [ - "Admin · Shard" + "Admin · Auth Providers" ], - "summary": "Resolve a help page without a reply (admin/moderator)", + "summary": "Update an SSO provider (admin only)", "description": "", "parameters": [ { @@ -4523,12 +696,174 @@ "schema": { "type": "string" }, - "description": "Page id (sender serial)." + "description": "Provider id." } ], "responses": { "200": { - "description": "Closed", + "description": "Updated provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderConfig" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Provider not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderCreateRequest" + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Auth Providers" + ], + "summary": "Delete a custom SSO provider (admin only)", + "description": "Built-in providers cannot be deleted — disable them instead.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id." + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedFlag" + } + } + } + }, + "400": { + "description": "Built-in provider cannot be deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Provider not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/bot-activity": { + "get": { + "tags": [ + "Admin · Bot Activity" + ], + "summary": "Bot-scoring / ban state and recent events (admin only)", + "description": "", + "responses": { + "200": { + "description": "Banned IPs, scores and recent events", "content": { "application/json": { "schema": { @@ -4538,11 +873,25 @@ } } }, - "400": { - "description": "Bad Request" + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "500": { - "description": "Internal Server Error" + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "security": [ @@ -4555,38 +904,53 @@ ] } }, - "/api/v1/admin/shard/audit": { - "get": { + "/api/v1/admin/bot-activity/unban": { + "post": { "tags": [ - "Admin · Shard" + "Admin · Bot Activity" ], - "summary": "Recent in-game moderation audit events (admin/moderator)", + "summary": "Emergency unban an IP (admin only)", "description": "", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { - "description": "admin.audit events, newest first", + "description": "Unbanned (echoes the ip and whether an entry was cleared)", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardEvent" - } + "$ref": "#/components/schemas/UnbanResult" } } } }, - "500": { - "description": "Internal Server Error" + "400": { + "description": "Invalid IP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "security": [ @@ -4596,42 +960,17 @@ { "bearerAuth": [] } - ] - } - }, - "/api/v1/admin/shard/houses": { - "get": { - "tags": [ - "Admin · Shard" ], - "summary": "Full house registry — owner, price, decay (admin/moderator)", - "description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.", - "responses": { - "200": { - "description": "Houses, ordered by name", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnbanRequest" } } - }, - "500": { - "description": "Internal Server Error" } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] + } } }, "/api/v1/admin/dashboard": { @@ -4714,30 +1053,82 @@ ] } }, - "/api/v1/admin/site-mode": { - "put": { + "/api/v1/admin/discord-bot/config": { + "get": { "tags": [ - "Admin · Dashboard" + "Admin · Discord Bot" ], - "summary": "Set site mode (admin only)", - "description": "Switch the site between live and maintenance.", + "summary": "Get Discord bot config + live status (admin only)", + "description": "", "responses": { "200": { - "description": "Updated site mode", + "description": "Masked config + live status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiteModeState" + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Discord Bot" + ], + "summary": "Save Discord bot config (admin only)", + "description": "token is write-only — omit/blank it to keep the existing one unchanged.", + "responses": { + "200": { + "description": "Updated config + live status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true } } } }, "400": { - "description": "Validation error", + "description": "Validation error, invalid token, or missing token while enabling", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationError" + "$ref": "#/components/schemas/Error" } } } @@ -4779,7 +1170,1608 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SiteModeRequest" + "type": "object", + "properties": { + "guildId": { + "type": "string" + }, + "token": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "/api/v1/admin/email/config": { + "get": { + "tags": [ + "Admin · Email" + ], + "summary": "Get email delivery config + status (admin only)", + "description": "", + "responses": { + "200": { + "description": "Config (refresh token stripped) + status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Email" + ], + "summary": "Update email delivery config (admin only)", + "description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.", + "responses": { + "200": { + "description": "Updated config", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Cannot enable before connecting a mailbox", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "senderName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "/api/v1/admin/email/connect/callback": { + "get": { + "tags": [ + "Admin · Email" + ], + "summary": "OAuth2 callback — stores the refresh token, redirects to Settings", + "description": "", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "error", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Redirect back to /admin/settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/email/connect/start": { + "get": { + "tags": [ + "Admin · Email" + ], + "summary": "Begin the Gmail OAuth2 connect flow (admin only)", + "description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.", + "responses": { + "200": { + "description": "Authorization URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Google OAuth client not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/email/disconnect": { + "post": { + "tags": [ + "Admin · Email" + ], + "summary": "Disconnect Gmail and disable email (admin only)", + "description": "", + "responses": { + "200": { + "description": "Disconnected config", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/email/test": { + "post": { + "tags": [ + "Admin · Email" + ], + "summary": "Send a test email (admin only)", + "description": "", + "responses": { + "200": { + "description": "Sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sent": { + "type": "boolean" + }, + "to": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "502": { + "description": "Send failed / not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "to": { + "type": "string", + "format": "email" + } + } + } + } + } + } + } + }, + "/api/v1/admin/invites": { + "post": { + "tags": [ + "Admin · Invites" + ], + "summary": "Create and email an account invite at a chosen access level", + "description": "", + "responses": { + "201": { + "description": "Invite created", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": {} + }, + "get": { + "tags": [ + "Admin · Invites" + ], + "summary": "List recent invites (no tokens)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Invites, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/invites/{id}": { + "delete": { + "tags": [ + "Admin · Invites" + ], + "summary": "Revoke a pending invite", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Invite id." + } + ], + "responses": { + "200": { + "description": "Revoked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No pending invite to revoke", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/appeals": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "List moderation appeals (default: pending + under_review)", + "description": "Filter with ?status= or ?status=all. Paginated with ?limit&offset.", + "responses": { + "200": { + "description": "Appeals queue", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealQueueItem" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/appeals/{id}": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Get a single moderation appeal", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Appeal id." + } + ], + "responses": { + "200": { + "description": "The appeal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppealQueueItem" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Appeal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/appeals/{id}/claim": { + "post": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Claim a pending appeal (→ under_review)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Appeal id." + } + ], + "responses": { + "200": { + "description": "The claimed appeal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppealQueueItem" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Appeal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Appeal is not open for claiming", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/appeals/{id}/resolve": { + "post": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Resolve an appeal (approved | denied); approval may auto-reverse the Discord action", + "description": "Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Appeal id." + } + ], + "responses": { + "200": { + "description": "The resolved appeal (with reversal outcome)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppealResolveResult" + } + } + } + }, + "400": { + "description": "Validation error (status must be approved or denied)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "Appeal not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Appeal is already resolved", + "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/ResolveAppealRequest" + } + } + } + } + } + }, + "/api/v1/admin/moderation/filter-hits": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent automated content-filter hits", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/members": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent member join/leave events (optionally filtered by type)", + "description": "", + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/recent": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent moderation actions, optionally filtered by type", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/search": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Look up moderated users by Discord id or username snapshot", + "description": "", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/spam-hits": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Recent automated spam-detection hits", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/stats/summary": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Moderation action counts for 24h/7d/30d (admin or moderator)", + "description": "", + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/user/{discordId}": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Per-user moderation summary (counts, latest tag, linked account)", + "description": "", + "parameters": [ + { + "name": "discordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/user/{discordId}/actions": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Full moderation action history for a user", + "description": "", + "parameters": [ + { + "name": "discordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/user/{discordId}/appeals": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Appeals submitted for a Discord user", + "description": "", + "parameters": [ + { + "name": "discordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Discord snowflake." + } + ], + "responses": { + "200": { + "description": "Appeals for the user", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealQueueItem" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/moderation/user/{discordId}/notes": { + "get": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Staff notes for a user (admin_only notes hidden from moderators)", + "description": "", + "parameters": [ + { + "name": "discordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Moderation" + ], + "summary": "Add a staff note (admin_only visibility requires the admin role)", + "description": "", + "parameters": [ + { + "name": "discordId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visibility": { + "example": "any" + }, + "body": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v1/admin/pages": { + "get": { + "tags": [ + "Admin · Pages" + ], + "summary": "List all CMS pages (summaries)", + "description": "", + "responses": { + "200": { + "description": "Page summaries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Pages" + ], + "summary": "Create a CMS page", + "description": "", + "responses": { + "201": { + "description": "Created page", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Invalid slug / title / blocks / metadata / settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Slug already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "blocks": { + "type": "array", + "items": { + "type": "object" + } + }, + "metadata": { + "type": "object" + }, + "settings": { + "type": "object" + } + } + } + } + } + } + } + }, + "/api/v1/admin/pages/{id}": { + "get": { + "tags": [ + "Admin · Pages" + ], + "summary": "Get a CMS page by id (full, incl. blocks)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + } + ], + "responses": { + "200": { + "description": "The page", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Admin · Pages" + ], + "summary": "Update a CMS page (title, status, blocks, metadata, settings)", + "description": "slug is immutable; disabling protection is rejected here (use /unprotect).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + } + ], + "responses": { + "200": { + "description": "Updated page", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error (slug immutable, invalid blocks, etc.)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Disabling protection requires /unprotect", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Pages" + ], + "summary": "Delete a CMS page (blocked if protected)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + } + ], + "responses": { + "200": { + "description": "Deleted (echoes the id)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedId" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Page is protected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/pages/{id}/preview": { + "post": { + "tags": [ + "Admin · Pages" + ], + "summary": "Mint a 1h draft-preview link for a page", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + } + ], + "responses": { + "200": { + "description": "Preview token + path", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresInSeconds": { + "type": "integer" + }, + "path": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/pages/{id}/unprotect": { + "post": { + "tags": [ + "Admin · Pages" + ], + "summary": "Disable page protection (password step-up re-auth)", + "description": "Verifies the current admin password server-side, then flips protected → false.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + } + ], + "responses": { + "200": { + "description": "Updated page (protected=false)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Password incorrect", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ] } } } @@ -4976,71 +2968,6 @@ } } }, - "/api/v1/admin/uploads": { - "post": { - "tags": [ - "Admin · Posts" - ], - "summary": "Upload an image for rich-text editors (multipart)", - "description": "", - "responses": { - "201": { - "description": "Stored file URL", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadResponse" - } - } - } - }, - "400": { - "description": "No file / disallowed type", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "image": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - } - }, "/api/v1/admin/posts/{id}": { "get": { "tags": [ @@ -5251,89 +3178,6 @@ ] } }, - "/api/v1/admin/posts/{id}/publish": { - "patch": { - "tags": [ - "Admin · Posts" - ], - "summary": "Publish / unpublish a post", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Post id." - } - ], - "responses": { - "200": { - "description": "Updated post", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Post" - } - } - } - }, - "400": { - "description": "Validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "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/PublishRequest" - } - } - } - } - } - }, "/api/v1/admin/posts/{id}/announce": { "get": { "tags": [ @@ -5471,6 +3315,2685 @@ } } }, + "/api/v1/admin/posts/{id}/publish": { + "patch": { + "tags": [ + "Admin · Posts" + ], + "summary": "Publish / unpublish a post", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Post id." + } + ], + "responses": { + "200": { + "description": "Updated post", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Post" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "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/PublishRequest" + } + } + } + } + } + }, + "/api/v1/admin/settings": { + "get": { + "tags": [ + "Admin · Settings" + ], + "summary": "Get all site settings (admin only)", + "description": "", + "responses": { + "200": { + "description": "All settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Settings" + ], + "summary": "Update site settings (admin only)", + "description": "", + "responses": { + "200": { + "description": "Updated settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Body must be an object of key/value settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "An object of key/value settings." + } + } + } + } + } + }, + "/api/v1/admin/shard/account": { + "post": { + "tags": [ + "Admin · Account" + ], + "summary": "Create a game account and link it to the caller (staff self-service)", + "description": "Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.", + "responses": { + "201": { + "description": "Account created and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Game-account signup unavailable (site or shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Account name already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": {} + } + }, + "/api/v1/admin/shard/accounts": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "List the caller’s linked game accounts (self)", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardLink" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/audit": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Recent in-game moderation audit events (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "admin.audit events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEvent" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/ban": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Ban an account, timed or indefinite (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Banned", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Protected target or write plane disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "serial": { + "type": "string" + }, + "durationSec": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/broadcast": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Broadcast a system message to everyone online (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Broadcast", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "hue": { + "type": "integer" + } + }, + "required": [ + "text" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/char/{serial}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Character sheet (self-linked characters; admins: any character)", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/houses": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Full house registry — owner, price, decay (admin/moderator)", + "description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.", + "responses": { + "200": { + "description": "Houses, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/kick": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Kick every live session of an account (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Kicked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Protected target or write plane disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "serial": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/link": { + "post": { + "tags": [ + "Admin · Account" + ], + "summary": "Link an in-game account with a one-time code (self)", + "description": "", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/admin/shard/pages": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Open help-page (support) queue (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Open pages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/pages/{id}/close": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Resolve a help page without a reply (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Page id (sender serial)." + } + ], + "responses": { + "200": { + "description": "Closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/pages/{id}/respond": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Reply to a help page, optionally closing it (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Page id (sender serial)." + } + ], + "responses": { + "200": { + "description": "Responded", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Unknown page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "close": { + "type": "boolean" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/roster/{account}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Character roster for an account (self; admins: any account)", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/sales": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts (self)", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardVendorSale" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/unban": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Clear an account ban (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Unbanned", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + } + }, + "required": [ + "account" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/vendors/{account}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Player vendors for an account (self; admins: any account)", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/site-mode": { + "put": { + "tags": [ + "Admin · Dashboard" + ], + "summary": "Set site mode (admin only)", + "description": "Switch the site between live and maintenance.", + "responses": { + "200": { + "description": "Updated site mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteModeState" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "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/SiteModeRequest" + } + } + } + } + } + }, + "/api/v1/admin/uo-link/config": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Get uo-link config + live status + ingestion stats (admin only)", + "description": "", + "responses": { + "200": { + "description": "Masked config, health and ingestion stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Save uo-link connection config (admin only)", + "description": "token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.", + "responses": { + "200": { + "description": "Updated config + live status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error, or missing token while enabling", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "wsUrl": { + "type": "string" + }, + "token": { + "type": "string" + }, + "protocol": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "/api/v1/admin/uo-link/stream": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Full live shard event stream incl. audit/cheat (SSE, admin only)", + "description": "", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + }, + "/api/v1/admin/uo-link/towncrier": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Publish / replace a town-crier message (admin only)", + "description": "", + "responses": { + "200": { + "description": "Posted", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Rejected (over caps)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TownCrierRequest" + } + } + } + } + } + }, + "/api/v1/admin/uo-link/towncrier/{id}": { + "delete": { + "tags": [ + "Admin · Shard" + ], + "summary": "Remove a town-crier message (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Town-crier message id." + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Unknown id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/uploads": { + "post": { + "tags": [ + "Admin · Posts" + ], + "summary": "Upload an image for rich-text editors (multipart)", + "description": "", + "responses": { + "201": { + "description": "Stored file URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResponse" + } + } + } + }, + "400": { + "description": "No file / disallowed type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "List users (admin only)", + "description": "", + "responses": { + "200": { + "description": "Users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Create a user (admin only)", + "description": "", + "responses": { + "201": { + "description": "Created user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Username already taken", + "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/UserCreateRequest" + } + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "put": { + "tags": [ + "Admin · Users" + ], + "summary": "Update a user (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Updated user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Validation error, or cannot demote the last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Username already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreateRequest" + } + } + } + } + }, + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Delete a user (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Deleted (echoes the id)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedId" + } + } + } + }, + "400": { + "description": "Cannot delete your own account or the last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Get a single user (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "The user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/mfa/reset": { + "post": { + "tags": [ + "Admin · Users" + ], + "summary": "Reset a user’s MFA (admin only)", + "description": "Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "MFA reset", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/accounts": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s linked game accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardLink" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/houses": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Houses owned by a user’s accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Houses (IDOC first)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/link/{account}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Unlink a game account from this user (admin only)", + "description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Game account to unlink." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "unlinked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Protected staff account (refused by shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/online": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s characters currently online (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Online characters", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/sales": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Recent vendor sales on a user’s accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardVendorSale" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/standing": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s shard standing — governorships held and guilds led (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Standing { governorOf, guildsLed }", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/trusted-devices": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "List a user’s trusted devices (admin only)", + "description": "Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Trusted devices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrustedDevice" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Revoke all of a user’s trusted devices (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Revoked count", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revoked": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/trusted-devices/{deviceId}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Revoke one of a user’s trusted devices (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "deviceId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Trusted-device id." + } + ], + "responses": { + "200": { + "description": "Revoked (idempotent)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revoked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/wiki": { + "get": { + "tags": [ + "Admin · Wiki" + ], + "summary": "List all wiki pages (including unpublished)", + "description": "", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Wiki pages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WikiPage" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Admin · Wiki" + ], + "summary": "Create a wiki page", + "description": "", + "responses": { + "201": { + "description": "Created wiki page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WikiPage" + } + } + } + }, + "400": { + "description": "Validation error or unknown category", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Slug already exists", + "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/WikiPageCreateRequest" + } + } + } + } + } + }, "/api/v1/admin/wiki/categories": { "get": { "tags": [ @@ -5787,151 +6310,6 @@ ] } }, - "/api/v1/admin/wiki": { - "get": { - "tags": [ - "Admin · Wiki" - ], - "summary": "List all wiki pages (including unpublished)", - "description": "", - "parameters": [ - { - "name": "q", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "category", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "tag", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "status", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Wiki pages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WikiPage" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Admin · Wiki" - ], - "summary": "Create a wiki page", - "description": "", - "responses": { - "201": { - "description": "Created wiki page", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WikiPage" - } - } - } - }, - "400": { - "description": "Validation error or unknown category", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Slug already exists", - "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/WikiPageCreateRequest" - } - } - } - } - } - }, "/api/v1/admin/wiki/{slug}": { "get": { "tags": [ @@ -6450,433 +6828,35 @@ ] } }, - "/api/v1/admin/pages": { + "/api/v1/auth/invite/{token}": { "get": { "tags": [ - "Admin · Pages" + "Auth" ], - "summary": "List all CMS pages (summaries)", - "description": "", - "responses": { - "200": { - "description": "Page summaries", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Admin · Pages" - ], - "summary": "Create a CMS page", - "description": "", - "responses": { - "201": { - "description": "Created page", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Invalid slug / title / blocks / metadata / settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Slug already exists", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "slug": { - "type": "string" - }, - "title": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "draft", - "published" - ] - }, - "blocks": { - "type": "array", - "items": { - "type": "object" - } - }, - "metadata": { - "type": "object" - }, - "settings": { - "type": "object" - } - } - } - } - } - } - } - }, - "/api/v1/admin/pages/{id}": { - "get": { - "tags": [ - "Admin · Pages" - ], - "summary": "Get a CMS page by id (full, incl. blocks)", - "description": "", + "summary": "Look up an email invite by token", + "description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.", "parameters": [ { - "name": "id", + "name": "token", "in": "path", "required": true, "schema": { - "type": "integer" - }, - "description": "Page id." + "type": "string" + } } ], "responses": { "200": { - "description": "The page", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "patch": { - "tags": [ - "Admin · Pages" - ], - "summary": "Update a CMS page (title, status, blocks, metadata, settings)", - "description": "slug is immutable; disabling protection is rejected here (use /unprotect).", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Page id." - } - ], - "responses": { - "200": { - "description": "Updated page", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Validation error (slug immutable, invalid blocks, etc.)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Disabling protection requires /unprotect", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "delete": { - "tags": [ - "Admin · Pages" - ], - "summary": "Delete a CMS page (blocked if protected)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Page id." - } - ], - "responses": { - "200": { - "description": "Deleted (echoes the id)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeletedId" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "403": { - "description": "Page is protected", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/pages/{id}/unprotect": { - "post": { - "tags": [ - "Admin · Pages" - ], - "summary": "Disable page protection (password step-up re-auth)", - "description": "Verifies the current admin password server-side, then flips protected → false.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Page id." - } - ], - "responses": { - "200": { - "description": "Updated page (protected=false)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Password incorrect", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "password": { - "type": "string" - } - }, - "required": [ - "password" - ] - } - } - } - } - } - }, - "/api/v1/admin/pages/{id}/preview": { - "post": { - "tags": [ - "Admin · Pages" - ], - "summary": "Mint a 1h draft-preview link for a page", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Page id." - } - ], - "responses": { - "200": { - "description": "Preview token + path", + "description": "Invite details", "content": { "application/json": { "schema": { "type": "object", "properties": { - "token": { + "email": { "type": "string" }, - "expiresInSeconds": { - "type": "integer" - }, - "path": { + "role": { "type": "string" } } @@ -6888,57 +6868,7 @@ "description": "Bad Request" }, "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/settings": { - "get": { - "tags": [ - "Admin · Settings" - ], - "summary": "Get all site settings (admin only)", - "description": "", - "responses": { - "200": { - "description": "All settings", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", + "description": "Invalid or expired invite", "content": { "application/json": { "schema": { @@ -6950,224 +6880,39 @@ "500": { "description": "Internal Server Error" } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Admin · Settings" - ], - "summary": "Update site settings (admin only)", - "description": "", - "responses": { - "200": { - "description": "Updated settings", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Body must be an object of key/value settings", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "description": "An object of key/value settings." - } - } - } } } }, - "/api/v1/admin/activity": { - "get": { + "/api/v1/auth/invite/{token}/accept": { + "post": { "tags": [ - "Admin · Activity" + "Auth" ], - "summary": "List recent admin activity", - "description": "", + "summary": "Accept an email invite (creates the account at the invited role)", + "description": "Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.", "parameters": [ { - "name": "offset", - "in": "query", + "name": "token", + "in": "path", + "required": true, "schema": { "type": "string" } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer" - }, - "description": "Max rows to return." } ], "responses": { "200": { - "description": "Activity entries", + "description": "Account created and session issued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/bot-activity": { - "get": { - "tags": [ - "Admin · Bot Activity" - ], - "summary": "Bot-scoring / ban state and recent events (admin only)", - "description": "", - "responses": { - "200": { - "description": "Banned IPs, scores and recent events", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/bot-activity/unban": { - "post": { - "tags": [ - "Admin · Bot Activity" - ], - "summary": "Emergency unban an IP (admin only)", - "description": "", - "responses": { - "200": { - "description": "Unbanned (echoes the ip and whether an entry was cleared)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnbanResult" + "$ref": "#/components/schemas/LoginResponse" } } } }, "400": { - "description": "Invalid IP", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -7176,636 +6921,8 @@ } } }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnbanRequest" - } - } - } - } - } - }, - "/api/v1/admin/discord-bot/config": { - "get": { - "tags": [ - "Admin · Discord Bot" - ], - "summary": "Get Discord bot config + live status (admin only)", - "description": "", - "responses": { - "200": { - "description": "Masked config + live status", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Admin · Discord Bot" - ], - "summary": "Save Discord bot config (admin only)", - "description": "token is write-only — omit/blank it to keep the existing one unchanged.", - "responses": { - "200": { - "description": "Updated config + live status", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Validation error, invalid token, or missing token while enabling", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "guildId": { - "type": "string" - }, - "token": { - "type": "string" - }, - "enabled": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "/api/v1/admin/email/config": { - "get": { - "tags": [ - "Admin · Email" - ], - "summary": "Get email delivery config + status (admin only)", - "description": "", - "responses": { - "200": { - "description": "Config (refresh token stripped) + status", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": [ - "Admin · Email" - ], - "summary": "Update email delivery config (admin only)", - "description": "Set the From display name and enabled toggle. Enabling requires a connected Gmail account.", - "responses": { - "200": { - "description": "Updated config", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Cannot enable before connecting a mailbox", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "senderName": { - "type": "string" - }, - "enabled": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "/api/v1/admin/email/connect/start": { - "get": { - "tags": [ - "Admin · Email" - ], - "summary": "Begin the Gmail OAuth2 connect flow (admin only)", - "description": "Returns { url } to redirect the browser to Google. Reuses the google SSO OAuth client.", - "responses": { - "200": { - "description": "Authorization URL", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Google OAuth client not configured", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/email/connect/callback": { - "get": { - "tags": [ - "Admin · Email" - ], - "summary": "OAuth2 callback — stores the refresh token, redirects to Settings", - "description": "", - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "state", - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "error", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "302": { - "description": "Redirect back to /admin/settings" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/email/test": { - "post": { - "tags": [ - "Admin · Email" - ], - "summary": "Send a test email (admin only)", - "description": "", - "responses": { - "200": { - "description": "Sent", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sent": { - "type": "boolean" - }, - "to": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "502": { - "description": "Send failed / not configured", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "to": { - "type": "string", - "format": "email" - } - } - } - } - } - } - } - }, - "/api/v1/admin/email/disconnect": { - "post": { - "tags": [ - "Admin · Email" - ], - "summary": "Disconnect Gmail and disable email (admin only)", - "description": "", - "responses": { - "200": { - "description": "Disconnected config", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/auth/providers": { - "get": { - "tags": [ - "Admin · Auth Providers" - ], - "summary": "List configured SSO providers (admin only)", - "description": "", - "responses": { - "200": { - "description": "Providers (secrets stripped)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderConfig" - } - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": [ - "Admin · Auth Providers" - ], - "summary": "Create a custom SSO provider (admin only)", - "description": "Built-in providers (google, discord) are configured via PUT, not created here.", - "responses": { - "201": { - "description": "Created provider", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderConfig" - } - } - } - }, - "400": { - "description": "Validation error, or a built-in/invalid kind", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", + "404": { + "description": "Invalid or expired invite", "content": { "application/json": { "schema": { @@ -7815,7 +6932,7 @@ } }, "409": { - "description": "Provider id already exists", + "description": "Username taken or invite already used", "content": { "application/json": { "schema": { @@ -7828,51 +6945,23 @@ "description": "Internal Server Error" } }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderCreateRequest" - } - } - } - } + "requestBody": {} } }, - "/api/v1/admin/auth/providers/{id}": { - "put": { + "/api/v1/auth/login": { + "post": { "tags": [ - "Admin · Auth Providers" - ], - "summary": "Update an SSO provider (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } + "Auth" ], + "summary": "Log in with username and password", + "description": "On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.", "responses": { "200": { - "description": "Updated provider", + "description": "Session issued, or TOTP challenge required", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderConfig" + "$ref": "#/components/schemas/LoginResponse" } } } @@ -7887,6 +6976,195 @@ } } }, + "401": { + "description": "Incorrect username or password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Too many attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + } + } + } + }, + "/api/v1/auth/login/totp": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Complete login with a TOTP or recovery code", + "description": "Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.", + "responses": { + "200": { + "description": "Session issued (optionally with a trusted-device-limit prompt)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Invalid code or expired challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpLoginRequest" + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Log out (clear the cookie and revoke this session)", + "description": "", + "responses": { + "200": { + "description": "Logged out", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Current authenticated user", + "description": "", + "responses": { + "200": { + "description": "The signed-in user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current account (self, any role)", + "description": "", + "responses": { + "200": { + "description": "The current account", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAccount" + } + } + } + }, "401": { "description": "Not authenticated", "content": { @@ -7898,7 +7176,7 @@ } }, "403": { - "description": "Admin role required", + "description": "Account not active", "content": { "application/json": { "schema": { @@ -7907,8 +7185,166 @@ } } }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/account/identities": { + "get": { + "tags": [ + "Auth · Me" + ], + "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/auth/me/account/identities/{provider}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "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": "Provider not found", + "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/auth/me/account/password": { + "patch": { + "tags": [ + "Auth · Me" + ], + "summary": "Change or set the current account’s password (self, any role)", + "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 own session is re-issued (they stay logged in) while older web 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": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "429": { + "description": "Too many changes (rate limited)", "content": { "application/json": { "schema": { @@ -7930,45 +7366,37 @@ } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreateRequest" + "$ref": "#/components/schemas/ChangePasswordRequest" } } } } - }, - "delete": { + } + }, + "/api/v1/auth/me/account/recovery-codes/generate": { + "post": { "tags": [ - "Admin · Auth Providers" - ], - "summary": "Delete a custom SSO provider (admin only)", - "description": "Built-in providers cannot be deleted — disable them instead.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Provider id." - } + "Auth · Me" ], + "summary": "Regenerate recovery codes (self, password step-up)", + "description": "Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.", "responses": { "200": { - "description": "Deleted", + "description": "New recovery codes (shown once)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletedFlag" + "$ref": "#/components/schemas/RecoveryCodes" } } } }, "400": { - "description": "Built-in provider cannot be deleted", + "description": "Wrong password, or two-factor not enabled", "content": { "application/json": { "schema": { @@ -7988,7 +7416,64 @@ } }, "403": { - "description": "Admin role required", + "description": "Forbidden" + }, + "429": { + "description": "Too Many Requests" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/auth/me/account/recovery-codes/status": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Remaining recovery-code count (self)", + "description": "", + "responses": { + "200": { + "description": "Remaining unused codes", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "remaining": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Not authenticated", "content": { "application/json": { "schema": { @@ -7997,15 +7482,8 @@ } } }, - "404": { - "description": "Provider not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "403": { + "description": "Forbidden" }, "500": { "description": "Internal Server Error" @@ -8021,309 +7499,36 @@ ] } }, - "/api/v1/admin/moderation/stats/summary": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Moderation action counts for 24h/7d/30d (admin or moderator)", - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/recent": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Recent moderation actions, optionally filtered by type", - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/search": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Look up moderated users by Discord id or username snapshot", - "description": "", - "parameters": [ - { - "name": "q", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/members": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Recent member join/leave events (optionally filtered by type)", - "description": "", - "parameters": [ - { - "name": "type", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/filter-hits": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Recent automated content-filter hits", - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/spam-hits": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Recent automated spam-detection hits", - "description": "", - "responses": { - "200": { - "description": "OK" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/user/{discordId}": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Per-user moderation summary (counts, latest tag, linked account)", - "description": "", - "parameters": [ - { - "name": "discordId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/user/{discordId}/actions": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Full moderation action history for a user", - "description": "", - "parameters": [ - { - "name": "discordId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/user/{discordId}/notes": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Staff notes for a user (admin_only notes hidden from moderators)", - "description": "", - "parameters": [ - { - "name": "discordId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "400": { - "description": "Bad Request" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, + "/api/v1/auth/me/account/totp/disable": { "post": { "tags": [ - "Admin · Moderation" - ], - "summary": "Add a staff note (admin_only visibility requires the admin role)", - "description": "", - "parameters": [ - { - "name": "discordId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } + "Auth · Me" ], + "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": { - "201": { - "description": "Created" + "200": { + "description": "2FA disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TotpState" + } + } + } }, "400": { - "description": "Bad Request" + "description": "Not enabled, or invalid code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" }, "403": { "description": "Forbidden" @@ -8341,149 +7546,37 @@ } ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "visibility": { - "example": "any" - }, - "body": { - "example": "any" - } - } + "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, - "/api/v1/admin/moderation/appeals": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "List moderation appeals (default: pending + under_review)", - "description": "Filter with ?status= or ?status=all. Paginated with ?limit&offset.", - "responses": { - "200": { - "description": "Appeals queue", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppealQueueItem" - } - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/appeals/{id}": { - "get": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Get a single moderation appeal", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Appeal id." - } - ], - "responses": { - "200": { - "description": "The appeal", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppealQueueItem" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Appeal not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/appeals/{id}/claim": { + "/api/v1/auth/me/account/totp/enable": { "post": { "tags": [ - "Admin · Moderation" + "Auth · Me" ], - "summary": "Claim a pending appeal (→ under_review)", + "summary": "Enable 2FA by confirming a code", "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Appeal id." - } - ], "responses": { "200": { - "description": "The claimed appeal", + "description": "2FA enabled", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppealQueueItem" + "$ref": "#/components/schemas/TotpState" } } } }, "400": { - "description": "Bad Request" - }, - "404": { - "description": "Appeal not found", + "description": "Setup not started, or invalid code", "content": { "application/json": { "schema": { @@ -8492,81 +7585,14 @@ } } }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, "409": { - "description": "Appeal is not open for claiming", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/moderation/appeals/{id}/resolve": { - "post": { - "tags": [ - "Admin · Moderation" - ], - "summary": "Resolve an appeal (approved | denied); approval may auto-reverse the Discord action", - "description": "Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Appeal id." - } - ], - "responses": { - "200": { - "description": "The resolved appeal (with reversal outcome)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppealResolveResult" - } - } - } - }, - "400": { - "description": "Validation error (status must be approved or denied)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "404": { - "description": "Appeal not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Appeal is already resolved", + "description": "Two-factor already enabled", "content": { "application/json": { "schema": { @@ -8592,47 +7618,53 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResolveAppealRequest" + "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, - "/api/v1/admin/moderation/user/{discordId}/appeals": { - "get": { + "/api/v1/auth/me/account/totp/setup": { + "post": { "tags": [ - "Admin · Moderation" + "Auth · Me" ], - "summary": "Appeals submitted for a Discord user", + "summary": "Begin 2FA enrollment (returns secret + QR)", "description": "", - "parameters": [ - { - "name": "discordId", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Discord snowflake." - } - ], "responses": { "200": { - "description": "Appeals for the user", + "description": "otpauth URL and QR data to scan", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppealQueueItem" - } + "$ref": "#/components/schemas/TotpSetup" } } } }, - "400": { - "description": "Bad Request" + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Two-factor already enabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, "500": { "description": "Internal Server Error" @@ -8648,22 +7680,175 @@ ] } }, - "/api/v1/admin/users": { - "get": { + "/api/v1/auth/me/account/username": { + "patch": { "tags": [ - "Admin · Users" + "Auth · Me" ], - "summary": "List users (admin only)", + "summary": "Change the current account’s username (self, any role)", "description": "", "responses": { "200": { - "description": "Users", + "description": "Updated username (session 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": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "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/auth/me/devices": { + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Register a push device (endpoint) for the current user", + "description": "Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).", + "responses": { + "201": { + "description": "Device registered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushDevice" + } + } + } + }, + "400": { + "description": "Validation error or disallowed endpoint", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterDeviceRequest" + } + } + } + } + }, + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "List the current user’s registered push devices", + "description": "", + "responses": { + "200": { + "description": "Registered devices", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/PushDevice" } } } @@ -8680,7 +7865,62 @@ } }, "403": { - "description": "Admin role required", + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/devices/{id}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Unregister a push device", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Device id (must belong to the caller)." + } + ], + "responses": { + "200": { + "description": "Unregistered", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkFlag" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such device for this user", "content": { "application/json": { "schema": { @@ -8701,20 +7941,110 @@ "bearerAuth": [] } ] - }, - "post": { + } + }, + "/api/v1/auth/me/notifications/streams": { + "get": { "tags": [ - "Admin · Users" + "Auth · Me" ], - "summary": "Create a user (admin only)", - "description": "", + "summary": "List subscribable notification streams (catalog)", + "description": "The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.", "responses": { - "201": { - "description": "Created user", + "200": { + "description": "Stream catalog", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/NotificationStreams" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/notifications/subscriptions": { + "get": { + "tags": [ + "Auth · Me" + ], + "summary": "Get the current user’s notification subscriptions", + "description": "", + "responses": { + "200": { + "description": "Subscribed stream ids", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptions" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Auth · Me" + ], + "summary": "Replace the current user’s notification subscriptions", + "description": "Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.", + "responses": { + "200": { + "description": "Updated subscriptions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSubscriptions" } } } @@ -8740,24 +8070,7 @@ } }, "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Username already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "description": "Forbidden" }, "500": { "description": "Internal Server Error" @@ -8776,285 +8089,34 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserCreateRequest" + "$ref": "#/components/schemas/NotificationSubscriptions" } } } } } }, - "/api/v1/admin/users/{id}": { - "put": { - "tags": [ - "Admin · Users" - ], - "summary": "Update a user (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Updated user", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "400": { - "description": "Validation error, or cannot demote the last admin", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Username already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserCreateRequest" - } - } - } - } - }, - "delete": { - "tags": [ - "Admin · Users" - ], - "summary": "Delete a user (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Deleted (echoes the id)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeletedId" - } - } - } - }, - "400": { - "description": "Cannot delete your own account or the last admin", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, + "/api/v1/auth/me/sessions": { "get": { "tags": [ - "Admin · Users" - ], - "summary": "Get a single user (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } + "Auth · Me" ], + "summary": "List active mobile device sessions (self)", + "description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.", "responses": { "200": { - "description": "The user", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/trusted-devices": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "List a user’s trusted devices (admin only)", - "description": "Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Trusted devices", + "description": "Active device sessions", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/TrustedDevice" + "$ref": "#/components/schemas/DeviceSession" } } } } }, - "400": { - "description": "Bad Request" - }, "401": { "description": "Not authenticated", "content": { @@ -9066,103 +8128,7 @@ } }, "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": [ - "Admin · Users" - ], - "summary": "Revoke all of a user’s trusted devices (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Revoked count", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "revoked": { - "type": "integer" - } - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "description": "Forbidden" }, "500": { "description": "Internal Server Error" @@ -9178,13 +8144,13 @@ ] } }, - "/api/v1/admin/users/{id}/trusted-devices/{deviceId}": { + "/api/v1/auth/me/sessions/{id}": { "delete": { "tags": [ - "Admin · Users" + "Auth · Me" ], - "summary": "Revoke one of a user’s trusted devices (admin only)", - "description": "", + "summary": "Revoke one mobile device session (self)", + "description": "Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).", "parameters": [ { "name": "id", @@ -9193,16 +8159,7 @@ "schema": { "type": "integer" }, - "description": "User id." - }, - { - "name": "deviceId", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Trusted-device id." + "description": "The session row id from GET /auth/me/sessions." } ], "responses": { @@ -9235,24 +8192,7 @@ } }, "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "description": "Forbidden" }, "500": { "description": "Internal Server Error" @@ -9268,31 +8208,66 @@ ] } }, - "/api/v1/admin/users/{id}/mfa/reset": { - "post": { + "/api/v1/auth/me/trusted-devices": { + "get": { "tags": [ - "Admin · Users" - ], - "summary": "Reset a user’s MFA (admin only)", - "description": "Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } + "Auth · Me" ], + "summary": "List trusted devices (self)", + "description": "Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.", "responses": { "200": { - "description": "MFA reset", + "description": "Active trusted devices", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OkFlag" + "type": "array", + "items": { + "$ref": "#/components/schemas/TrustedDevice" + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Auth · Me" + ], + "summary": "Trust the current device (self)", + "description": "Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native to store. Returns 409 { error: \"trusted_device_limit\", devices } when the per-user cap is reached — revoke one first, then retry.", + "responses": { + "200": { + "description": "Device trusted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustDeviceResult" } } } @@ -9311,21 +8286,14 @@ } }, "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "description": "Forbidden" }, - "404": { - "description": "Not found", + "409": { + "description": "Trusted-device limit reached", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/TrustedDeviceLimit" } } } @@ -9341,343 +8309,98 @@ { "bearerAuth": [] } - ] - } - }, - "/api/v1/admin/users/{id}/shard/accounts": { - "get": { - "tags": [ - "Admin · Users" ], - "summary": "A user’s linked game accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deviceName": { + "type": "string" } } } } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/sales": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "Recent vendor sales on a user’s accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/houses": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "Houses owned by a user’s accounts (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Houses (IDOC first)", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/online": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "A user’s characters currently online (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Online characters", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/standing": { - "get": { - "tags": [ - "Admin · Users" - ], - "summary": "A user’s shard standing — governorships held and guilds led (admin only)", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - } - ], - "responses": { - "200": { - "description": "Standing { governorOf, guildsLed }", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/users/{id}/shard/link/{account}": { + } + }, "delete": { "tags": [ - "Admin · Users" - ], - "summary": "Unlink a game account from this user (admin only)", - "description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "User id." - }, - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Game account to unlink." - } + "Auth · Me" ], + "summary": "Revoke all trusted devices (self)", + "description": "Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.", "responses": { "200": { - "description": "Unlinked", + "description": "Revoked count", "content": { "application/json": { "schema": { "type": "object", "properties": { - "account": { - "type": "string" - }, - "unlinked": { + "revoked": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/auth/me/trusted-devices/{id}": { + "delete": { + "tags": [ + "Auth · Me" + ], + "summary": "Revoke one trusted device (self)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Trusted-device id from GET /auth/me/trusted-devices." + } + ], + "responses": { + "200": { + "description": "Revoked (idempotent)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revoked": { "type": "boolean" } } @@ -9688,8 +8411,8 @@ "400": { "description": "Bad Request" }, - "403": { - "description": "Protected staff account (refused by shard)", + "401": { + "description": "Not authenticated", "content": { "application/json": { "schema": { @@ -9698,24 +8421,11 @@ } } }, - "404": { - "description": "Not linked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "403": { + "description": "Forbidden" }, "500": { "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Service Unavailable" } }, "security": [ @@ -9728,21 +8438,20 @@ ] } }, - "/api/v1/admin/invites": { + "/api/v1/auth/mobile/login": { "post": { "tags": [ - "Admin · Invites" + "Auth · Mobile" ], - "summary": "Create and email an account invite at a chosen access level", - "description": "", + "summary": "Native login → access + refresh tokens", + "description": "Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.", "responses": { - "201": { - "description": "Invite created", + "200": { + "description": "Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/MobileTokenResponse" } } } @@ -9757,46 +8466,22 @@ } } }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": {} - }, - "get": { - "tags": [ - "Admin · Invites" - ], - "summary": "List recent invites (no tokens)", - "description": "", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Invites, newest first", + "401": { + "description": "Invalid credentials, or a TOTP code is required", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } } @@ -9805,42 +8490,32 @@ "description": "Internal Server Error" } }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileLoginRequest" + } + } } - ] + } } }, - "/api/v1/admin/invites/{id}": { - "delete": { + "/api/v1/auth/mobile/logout": { + "post": { "tags": [ - "Admin · Invites" - ], - "summary": "Revoke a pending invite", - "description": "", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - }, - "description": "Invite id." - } + "Auth · Mobile" ], + "summary": "Revoke the current (or all) refresh tokens", + "description": "Requires a valid bearer access token. Revokes the given refresh token, or every session for the user when { all: true }. Idempotent.", "responses": { "200": { - "description": "Revoked", + "description": "Logged out", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/Message" } } } @@ -9848,8 +8523,8 @@ "400": { "description": "Bad Request" }, - "404": { - "description": "No pending invite to revoke", + "401": { + "description": "Missing or invalid bearer token", "content": { "application/json": { "schema": { @@ -9858,82 +8533,59 @@ } } }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/admin/uo-link/config": { - "get": { - "tags": [ - "Admin · Shard" - ], - "summary": "Get uo-link config + live status + ingestion stats (admin only)", - "description": "", - "responses": { - "200": { - "description": "Masked config, health and ingestion stats", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, "403": { - "description": "Admin role required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } + "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ - { - "cookieAuth": [] - }, { "bearerAuth": [] } - ] - }, - "put": { - "tags": [ - "Admin · Shard" ], - "summary": "Save uo-link connection config (admin only)", - "description": "token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileLogoutRequest" + } + } + } + } + } + }, + "/api/v1/auth/mobile/refresh": { + "post": { + "tags": [ + "Auth · Mobile" + ], + "summary": "Rotate a refresh token for a fresh token pair", + "description": "Refresh tokens are single-use: the presented token is revoked and a new access + refresh pair is issued. Reusing a rotated token fails with 401.", "responses": { "200": { - "description": "Updated config + live status", + "description": "New access + refresh tokens", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/MobileTokenResponse" } } } }, "400": { - "description": "Validation error, or missing token while enabling", + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Invalid or expired session", "content": { "application/json": { "schema": { @@ -9942,8 +8594,8 @@ } } }, - "403": { - "description": "Admin role required", + "429": { + "description": "Too many refresh attempts", "content": { "application/json": { "schema": { @@ -9956,35 +8608,206 @@ "description": "Internal Server Error" } }, - "security": [ + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileRefreshRequest" + } + } + } + } + } + }, + "/api/v1/auth/mobile/sso/exchange": { + "post": { + "tags": [ + "Auth · Mobile" + ], + "summary": "Exchange an SSO authorization code for mobile tokens", + "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.", + "responses": { + "200": { + "description": "Access + refresh tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileTokenResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Invalid/expired/used code or failed PKCE verification", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileSsoExchangeRequest" + } + } + } + } + } + }, + "/api/v1/auth/mobile/sso/start": { + "get": { + "tags": [ + "Auth · Mobile" + ], + "summary": "Begin native SSO login (redirect to the IdP)", + "description": "Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.", + "parameters": [ { - "cookieAuth": [] + "name": "provider", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id from GET /auth/providers (e.g. google, discord)." }, { - "bearerAuth": [] + "name": "code_challenge", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "App-generated PKCE S256 challenge (base64url)." + }, + { + "name": "state", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "App-generated opaque CSRF value, echoed on the callback for the app to verify." + }, + { + "name": "redirect_uri", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)." } ], + "responses": { + "302": { + "description": "Redirect to the identity provider (or back to the app callback on error)" + }, + "400": { + "description": "Unrecognized redirect URI or validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/auth/password/forgot": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Request a password-reset link by email", + "description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.", + "responses": { + "200": { + "description": "Generic acknowledgement (sent if the account exists)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "429": { + "description": "Too many requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "email" + ], "properties": { - "baseUrl": { - "type": "string" - }, - "wsUrl": { - "type": "string" - }, - "token": { - "type": "string" - }, - "protocol": { - "type": "integer" - }, - "enabled": { - "type": "boolean" + "email": { + "type": "string", + "format": "email" } } } @@ -9993,98 +8816,35 @@ } } }, - "/api/v1/admin/uo-link/towncrier": { - "post": { + "/api/v1/auth/password/reset/{token}": { + "get": { "tags": [ - "Admin · Shard" + "Auth" ], - "summary": "Publish / replace a town-crier message (admin only)", - "description": "", - "responses": { - "200": { - "description": "Posted", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Rejected (over caps)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TownCrierRequest" - } - } - } - } - } - }, - "/api/v1/admin/uo-link/towncrier/{id}": { - "delete": { - "tags": [ - "Admin · Shard" - ], - "summary": "Remove a town-crier message (admin only)", - "description": "", + "summary": "Validate a password-reset link", + "description": "Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", "parameters": [ { - "name": "id", + "name": "token", "in": "path", "required": true, "schema": { "type": "string" - }, - "description": "Town-crier message id." + } } ], "responses": { "200": { - "description": "Removed", + "description": "Reset link is valid", "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": true + "properties": { + "username": { + "type": "string" + } + } } } } @@ -10093,7 +8853,7 @@ "description": "Bad Request" }, "404": { - "description": "Unknown id", + "description": "Invalid or expired reset link", "content": { "application/json": { "schema": { @@ -10104,12 +8864,359 @@ }, "500": { "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "Auth" + ], + "summary": "Set a new password from a reset link", + "description": "Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Password changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } }, - "502": { - "description": "Bad Gateway" + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } }, - "503": { - "description": "Service Unavailable" + "404": { + "description": "Invalid, expired, or already-used reset link", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many attempts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string", + "minLength": 8, + "maxLength": 64 + } + } + } + } + } + } + } + }, + "/api/v1/auth/providers": { + "get": { + "tags": [ + "Auth · SSO" + ], + "summary": "List enabled SSO providers", + "description": "Public discovery used by the login page to render provider buttons. Never exposes secrets.", + "responses": { + "200": { + "description": "Enabled, valid providers", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Register a player account", + "description": "Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.", + "responses": { + "200": { + "description": "Account created and session issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Validation error or unavailable username", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "403": { + "description": "Registration is not open", + "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 attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + } + } + } + }, + "/api/v1/auth/sso/totp": { + "post": { + "tags": [ + "Auth · SSO" + ], + "summary": "Complete an SSO login with a TOTP code", + "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.", + "responses": { + "200": { + "description": "Session issued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/SafeUser" + }, + "returnTo": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Invalid code or expired challenge", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Conflict" + }, + "429": { + "description": "Too many attempts (rate limited / backoff)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/auth/sso/{provider}/callback": { + "get": { + "tags": [ + "Auth · SSO" + ], + "summary": "OAuth redirect target — completes login or linking", + "description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id (e.g. google, discord)." + }, + { + "name": "error", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "OAuth authorization code." + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "OAuth state (matched against the tx cookie)." + } + ], + "responses": { + "302": { + "description": "Redirect into /admin on success, or back to login/account with an error code" + } + } + } + }, + "/api/v1/auth/sso/{provider}/link": { + "get": { + "tags": [ + "Auth · SSO" + ], + "summary": "Begin linking an SSO identity to the current account", + "description": "Requires an authenticated session; the signed transaction captures the acting user so the callback can attach the external identity.", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id (e.g. google, discord)." + } + ], + "responses": { + "302": { + "description": "Redirect to the identity provider (or back to the account page on error)" + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" } }, "security": [ @@ -10122,16 +9229,36 @@ ] } }, - "/api/v1/admin/uo-link/stream": { + "/api/v1/auth/sso/{provider}/start": { "get": { "tags": [ - "Admin · Shard" + "Auth · SSO" + ], + "summary": "Begin SSO login (redirect to the IdP)", + "description": "Sets a short-lived signed transaction cookie and 302-redirects to the provider authorize URL. On error redirects back to the login page with an sso_error query param.", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Provider id (e.g. google, discord)." + }, + { + "name": "returnTo", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Internal /admin path to return to after login." + } ], - "summary": "Full live shard event stream incl. audit/cheat (SSE, admin only)", - "description": "", "responses": { - "200": { - "description": "An SSE stream (Content-Type: text/event-stream)." + "302": { + "description": "Redirect to the identity provider (or back to the login page on error)" } } } @@ -10188,6 +9315,358 @@ ] } }, + "/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": [ @@ -10278,862 +9757,6 @@ } } }, - "/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/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/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/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/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/shard/link": { - "post": { - "tags": [ - "Player · Shard" - ], - "summary": "Link an in-game account with a one-time code", - "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.", - "responses": { - "200": { - "description": "Linked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkResult" - } - } - } - }, - "400": { - "description": "Unknown or expired code", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShardLinkRequest" - } - } - } - } - } - }, - "/api/v1/player/shard/account": { - "post": { - "tags": [ - "Player · Shard" - ], - "summary": "Create a game account (hybrid signup) and link it to the caller", - "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.", - "responses": { - "201": { - "description": "Account created and linked", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "account": { - "type": "string" - }, - "linked": { - "type": "boolean" - } - } - } - } - } - }, - "400": { - "description": "Validation error or rejected name/password", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Game-account signup unavailable (site or shard)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Account name already taken", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "429": { - "description": "Per-IP account cap reached", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "requestBody": {} - } - }, - "/api/v1/player/shard/accounts": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "List the caller’s linked game accounts", - "description": "", - "responses": { - "200": { - "description": "Linked accounts", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardLink" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/roster/{account}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Character roster for a linked account", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Account roster", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/vendors/{account}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Player vendors for a linked account", - "description": "", - "parameters": [ - { - "name": "account", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "A game account linked to the caller." - } - ], - "responses": { - "200": { - "description": "Vendor snapshot", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Account not linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Internal Server Error" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/char/{serial}": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Character sheet — only for a character on the caller’s linked account", - "description": "", - "parameters": [ - { - "name": "serial", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Mobile serial, e.g. 0x24C." - } - ], - "responses": { - "200": { - "description": "Character profile", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Character not on an account linked to the caller", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "Not Found" - }, - "500": { - "description": "Internal Server Error" - }, - "502": { - "description": "Bad Gateway" - }, - "503": { - "description": "Shard unavailable — retry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/sales": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "Recent player-vendor sales for the caller’s linked accounts", - "description": "", - "responses": { - "200": { - "description": "Vendor sales", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardVendorSale" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, - "/api/v1/player/shard/houses": { - "get": { - "tags": [ - "Player · Shard" - ], - "summary": "The caller’s own houses (home status)", - "description": "Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.", - "responses": { - "200": { - "description": "The caller’s houses", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShardHouse" - } - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ] - } - }, "/api/v1/player/appeals": { "get": { "tags": [ @@ -11384,6 +10007,1383 @@ } ] } + }, + "/api/v1/player/shard/account": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Create a game account (hybrid signup) and link it to the caller", + "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.", + "responses": { + "201": { + "description": "Account created and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "linked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Validation error or rejected name/password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Game-account signup unavailable (site or shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Account name already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Per-IP account cap reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": {} + } + }, + "/api/v1/player/shard/accounts": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "List the caller’s linked game accounts", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardLink" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/char/{serial}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character sheet — only for a character on the caller’s linked account", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/houses": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "The caller’s own houses (home status)", + "description": "Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.", + "responses": { + "200": { + "description": "The caller’s houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/link": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Link an in-game account with a one-time code", + "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.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/shard/roster/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character roster for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/sales": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardVendorSale" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/vendors/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Player vendors for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/public/contact": { + "post": { + "tags": [ + "Public" + ], + "summary": "Send a contact message", + "description": "Emails the site owner (or falls back to a mailto). Rate limited.", + "responses": { + "200": { + "description": "Message sent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "429": { + "description": "Too many messages (rate limited)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "502": { + "description": "Mail delivery failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContactRequest" + } + } + } + } + } + }, + "/api/v1/public/pages/{id}/preview/{token}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Render a page from a draft-preview token", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Page id." + }, + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Preview token from POST /admin/pages/:id/preview." + } + ], + "responses": { + "200": { + "description": "The page (any status)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Token invalid/expired or page missing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/pages/{slug}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get a published CMS page by slug", + "description": "Drafts 404 for the public; staff sessions see drafts. Gated by site mode.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Page slug." + } + ], + "responses": { + "200": { + "description": "The page", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/posts/{category}": { + "get": { + "tags": [ + "Public" + ], + "summary": "List published posts in a category", + "description": "Gated by site mode: during maintenance only admins with a valid session see content.", + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "news | five-on-friday | newsletter | screenshots" + } + ], + "responses": { + "200": { + "description": "Published posts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Post" + } + } + } + } + }, + "404": { + "description": "Unknown category", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/posts/{category}/{idOrSlug}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get a single published post", + "description": "", + "parameters": [ + { + "name": "category", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Post category." + }, + { + "name": "idOrSlug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Numeric id or slug." + } + ], + "responses": { + "200": { + "description": "The post", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Post" + } + } + } + }, + "404": { + "description": "Unknown category or post not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/settings": { + "get": { + "tags": [ + "Public" + ], + "summary": "Public site settings + branding", + "description": "Whitelisted, non-sensitive settings plus the per-shard brand block (name/colors/logo/hero/favicon) a client themes itself from, and derived registration / game-account-signup availability flags.", + "responses": { + "200": { + "description": "Public settings + branding", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSettings" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/champs": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current champion-spawn board (all categories)", + "description": "The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.", + "responses": { + "200": { + "description": "Champion spawns, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/economy": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Gold-supply time series (oldest → newest)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max samples (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Economy samples", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEconomyPoint" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/feed": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Recent notable shard events (from the ingested log)", + "description": "", + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter to a single event kind, e.g. vendor.sale." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max rows (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardEvent" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current town-governor board (City Loyalty)", + "description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.", + "responses": { + "200": { + "description": "Cities, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors/{city}/history": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Governor term history for a city", + "description": "", + "parameters": [ + { + "name": "city", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "City name, e.g. Britain." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max terms (default 100, max 500)." + } + ], + "responses": { + "200": { + "description": "Terms, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/guilds": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current guild board (rosters, alliances, leaders)", + "description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.", + "responses": { + "200": { + "description": "Guilds, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/houses": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "House registry (owner, co-owners, price, decay)", + "description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.", + "responses": { + "200": { + "description": "Houses, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/idoc": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Houses currently in danger (IDOC)", + "description": "", + "responses": { + "200": { + "description": "IDOC houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/online": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Staff online now (linked staff accounts; location is admin/moderator-only)", + "description": "", + "responses": { + "200": { + "description": "Online players", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardOnlinePlayer" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/presence": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Online population aggregate (count + per-facet + per-region)", + "description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.", + "responses": { + "200": { + "description": "Population snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/status": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Shard connection state, online count and latest economy", + "description": "", + "responses": { + "200": { + "description": "Shard status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShardStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/stream": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Live shard event stream (Server-Sent Events, public/safe kinds)", + "description": "text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + }, + "/api/v1/public/status": { + "get": { + "tags": [ + "Public" + ], + "summary": "Site mode / status", + "description": "Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.", + "responses": { + "200": { + "description": "Site status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/version": { + "get": { + "tags": [ + "Public" + ], + "summary": "Backend identity + version", + "description": "Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.", + "responses": { + "200": { + "description": "Backend version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicVersion" + } + } + } + } + } + } + }, + "/api/v1/public/wiki": { + "get": { + "tags": [ + "Public" + ], + "summary": "List published wiki pages", + "description": "", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Published wiki pages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WikiPage" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/wiki/categories": { + "get": { + "tags": [ + "Public" + ], + "summary": "List wiki categories", + "description": "", + "responses": { + "200": { + "description": "Wiki categories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WikiCategory" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/wiki/tags": { + "get": { + "tags": [ + "Public" + ], + "summary": "List wiki tags", + "description": "", + "responses": { + "200": { + "description": "Wiki tags", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/wiki/{slug}": { + "get": { + "tags": [ + "Public" + ], + "summary": "Get a single published wiki page", + "description": "", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Wiki page slug." + } + ], + "responses": { + "200": { + "description": "The wiki page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WikiPage" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } } }, "components": { @@ -17652,4 +17652,4 @@ } } } -} \ No newline at end of file +} diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index e49a350..ff01550 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -11,6 +11,8 @@ // Regenerate with: npm run swagger (from the server/ directory) // The generated JSON is committed so the docs work without a build step. +const fs = require('fs') + const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const pkg = require('../package.json') const brand = require('../src/config/brand') @@ -916,7 +918,46 @@ const doc = { }, } +/** + * Normalize `/a/b/` → `/a/b` in the generated path keys. + * + * swagger-autogen builds a path by string-concatenating the mount prefix with the + * route argument, so a capability router mounted at `/users` that declares its + * collection route as `router.get('/')` documents as `/api/v1/admin/users/`. + * Express itself does not care (non-strict routing treats the two as one route, + * and server/routes.manifest.json records the canonical slash-less form), but the + * *spec* would advertise a URL no client uses and stop documenting the one they + * all call. The domain split (docs/website/API_V2_PLAN.md § Phase 2) creates one + * of these per capability router, so it is fixed here once rather than by + * contorting the route declarations in every router file. + * + * The path keys are also **sorted**. swagger-autogen emits them in router-traversal + * order, so moving a route between files rewrites most of this 5k-line committed + * artifact even when the API is provably unchanged — burying the one line a + * reviewer needs to see. OpenAPI attaches no meaning to path order, and + * scripts/routeManifest.js already sorts for the same reason. + */ +function normalizePaths(spec) { + const paths = {} + for (const [p, item] of Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + const key = p.length > 1 ? p.replace(/\/+$/, '') : p + if (paths[key]) { + // Two different declarations collapsed onto one path — merging would hide + // whichever lost. Nothing in the tree does this today; fail loudly if it starts. + throw new Error( + `swagger: "${p}" and "${key}" collide after trailing-slash normalization. ` + + 'Two routes are documenting the same URL — reconcile them in the router.', + ) + } + paths[key] = item + } + spec.paths = paths + return spec +} + swaggerAutogen(outputFile, routes, doc).then(() => { + const written = JSON.parse(fs.readFileSync(outputFile, 'utf8')) + fs.writeFileSync(outputFile, `${JSON.stringify(normalizePaths(written), null, 2)}\n`) // eslint-disable-next-line no-console console.log('swagger-output.json generated.') })