From 1a61cd1638d97f5fddbb769080b08fab6240020a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 15:49:31 -0500 Subject: [PATCH 1/2] build(swagger): normalize and sort generated OpenAPI path keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the committed spec for the admin router domain split (docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's output in swagger/swagger.js. No route, handler or annotation changes. Trailing slashes are stripped from path keys. swagger-autogen builds a path by string-concatenating the mount prefix with the route argument, so a capability router mounted at /users whose collection route is router.get('/') documents as /api/v1/admin/users/ — advertising a URL no client calls while dropping the one the SPA, the Android app and the Discord bot all do. Express is indifferent (non-strict routing treats the two as one route, and routes.manifest.json records the canonical slash-less form), but the published spec is a contract. The split creates one of these per capability router, so it is fixed once here rather than by contorting the route declarations in every router file. Path keys are also sorted. The generator 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. Verified inert: the regenerated spec is byte-for-byte the sorted form of the previously committed one — same 198 operations, zero added or removed, and no trailing-slash keys (there were none to strip yet; the guard is for the split). A collision after normalization throws rather than silently dropping an operation. Server tests green (434/434). Co-Authored-By: Claude --- server/swagger/swagger-output.json | 16258 +++++++++++++-------------- server/swagger/swagger.js | 41 + 2 files changed, 8170 insertions(+), 8129 deletions(-) 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.') }) -- 2.49.1 From 8ad892725f98f35421ac7651c472fe522dc05eab Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 27 Jul 2026 15:54:00 -0500 Subject: [PATCH 2/2] refactor(server): split admin users, account, invites and auth providers into capability routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure mechanical re-wiring: routes move between files, no handler, gate, validator or annotation changes, and not one URL moves. New src/router/v1/admin/index.js owns the two things the group shares — the `noindex, isLoggedIn, staffOnly` gate and the mount table — and declares no routes itself. The gate sits ahead of every mount so a capability router extracted in a later PR cannot silently ship without it. Four capability routers mount at the prefix they already owned inside the monolith: account.router.js 6 routes -> /admin/account (self-service, no adminOnly) users.router.js 15 routes -> /admin/users (adminOnly, router-level) invites.router.js 3 routes -> /admin/invites (adminOnly, per-route) authProviders.router.js 4 routes -> /admin/auth (adminOnly, per-route) admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin routes) and is mounted last at the group root; none of the four prefixes appears in it, so nothing depends on mount ordering. It disappears when PR 5 lands. Handlers still live in admin.controller.js and usersShard.controller.js — this re-wires routes, not logic. `adminOnly` moves with the routes that use it, and `usersRouter.use(adminOnly)` is exactly equivalent to the old `adminRouter.use('/users', adminOnly)` now that the router is mounted at /users. All three generated gates are zero-diff: routes.manifest.json unchanged (200 public + 2 internal) routes.guards.json unchanged — no route lost or gained a gate swagger-output.json unchanged, byte-for-byte The spec staying byte-identical depends on the path normalization landed in the preceding commit; without it the four collection routes would have documented as /api/v1/admin/{users,invites,account}/ with a trailing slash. Server tests green (434/434). Co-Authored-By: Claude --- server/src/router/v1/admin/account.router.js | 87 ++++ server/src/router/v1/admin/admin.routes.js | 446 +----------------- .../router/v1/admin/authProviders.router.js | 102 ++++ server/src/router/v1/admin/index.js | 46 ++ server/src/router/v1/admin/invites.router.js | 54 +++ server/src/router/v1/admin/users.router.js | 249 ++++++++++ server/src/router/v1/v1.router.js | 2 +- 7 files changed, 551 insertions(+), 435 deletions(-) create mode 100644 server/src/router/v1/admin/account.router.js create mode 100644 server/src/router/v1/admin/authProviders.router.js create mode 100644 server/src/router/v1/admin/index.js create mode 100644 server/src/router/v1/admin/invites.router.js create mode 100644 server/src/router/v1/admin/users.router.js 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) -- 2.49.1