// ── OpenAPI spec generator (swagger-autogen) ─────────────────────────────── // // Static-analyzes the Express routers and emits `swagger-output.json`, which is // served by swagger-ui-express at /api/docs (see src/app.js). Per-endpoint // details — tags, summaries, parameters, request bodies, security and response // codes — live as `#swagger.*` comments next to each route in // src/router/**. This file supplies everything shared: API metadata, servers, // tag descriptions, the two auth schemes (session cookie + mobile bearer), and // the reusable component schemas the annotations reference. // // Regenerate with: npm run swagger (from the server/ directory) // The generated JSON is committed so the docs work without a build step. const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const pkg = require('../package.json') const outputFile = './swagger/swagger-output.json' // Entry point of the routing graph. swagger-autogen follows the `app.use(...)` // mount chain from here (/api → /v1 → auth|public|admin), so generated paths are // fully-qualified (e.g. /api/v1/auth/login). const routes = ['./src/app.js'] const doc = { info: { title: 'UOMysticmoon API', version: pkg.version, description: 'REST API for the UOMysticmoon website, wiki and admin panel — a private ' + 'Ultima Online shard.\n\n' + '### Authentication\n' + '- **Web / admin panel** uses an httpOnly session cookie (`uomm_token`) issued by ' + '`POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n' + '- **Native / mobile clients** use bearer access tokens from ' + '`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' + 'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' + 'restricted to the `admin` role (editors are limited to content).', }, servers: [ { url: '/', description: 'Same-origin (current host)' }, { url: 'http://localhost:3000', description: 'Local development' }, ], tags: [ { name: 'Health', description: 'Liveness probe' }, { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' }, { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, { name: 'Admin · Settings', description: 'Site settings (admin only)' }, { name: 'Admin · Activity', description: 'Admin activity log' }, { name: 'Admin · Bot Activity', description: 'Bot-scoring/ban state and emergency unban (admin only)' }, { name: 'Admin · Discord Bot', description: 'Discord bot token/config and live status (admin only)' }, { name: 'Admin · Auth Providers', description: 'SSO provider configuration (admin only)' }, { name: 'Admin · Users', description: 'User management (admin only)' }, ], components: { securitySchemes: { // Web/admin session — httpOnly cookie set by the login endpoints. cookieAuth: { type: 'apiKey', in: 'cookie', name: 'uomm_token', description: 'Session JWT set as an httpOnly cookie by POST /api/v1/auth/login.', }, // Native/mobile clients — Authorization: Bearer . bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'Access token from POST /api/v1/auth/mobile/login (or /refresh).', }, }, schemas: { Error: { type: 'object', properties: { message: { type: 'string', example: 'Not found' } }, }, ValidationError: { type: 'object', properties: { errors: { type: 'array', items: { type: 'object', properties: { type: { type: 'string', example: 'field' }, msg: { type: 'string', example: 'Invalid value' }, path: { type: 'string', example: 'username' }, location: { type: 'string', example: 'body' }, }, }, }, }, }, SafeUser: { type: 'object', properties: { id: { type: 'integer', example: 1 }, username: { type: 'string', example: 'admin' }, role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, }, }, LoginRequest: { type: 'object', required: ['username', 'password'], properties: { username: { type: 'string', example: 'admin' }, password: { type: 'string', format: 'password', example: 'super-secret' }, company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' }, }, }, RegisterRequest: { type: 'object', required: ['username', 'password'], properties: { username: { type: 'string', minLength: 3, maxLength: 32, example: 'newplayer' }, password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 }, email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' }, company: { type: 'string', description: 'Honeypot — must be empty for humans.', example: '' }, }, }, LoginResponse: { type: 'object', description: 'Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp.', properties: { user: { $ref: '#/components/schemas/SafeUser' }, totpRequired: { type: 'boolean', example: true }, challenge: { type: 'string', description: 'Signed challenge token for the TOTP step.' }, }, }, TotpLoginRequest: { type: 'object', required: ['challenge', 'code'], properties: { challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' }, code: { type: 'string', example: '123456' }, }, }, MobileLoginRequest: { type: 'object', required: ['username', 'password'], properties: { username: { type: 'string', example: 'admin' }, password: { type: 'string', format: 'password', example: 'super-secret' }, code: { type: 'string', description: 'TOTP code (only when 2FA is enabled).', example: '123456' }, }, }, MobileTokenResponse: { type: 'object', properties: { accessToken: { type: 'string', description: 'Short-lived bearer JWT.' }, refreshToken: { type: 'string', description: 'Long-lived, revocable refresh token.' }, expiresIn: { type: 'string', description: 'Access token lifetime as a duration string (zeit/ms format, e.g. "15m").', example: '15m', }, user: { $ref: '#/components/schemas/SafeUser' }, }, }, MobileRefreshRequest: { type: 'object', required: ['refreshToken'], properties: { refreshToken: { type: 'string' } }, }, MobileLogoutRequest: { type: 'object', properties: { refreshToken: { type: 'string', description: 'Revoke a single session.' }, all: { type: 'boolean', description: 'Revoke every session for the user.', example: false }, }, }, Message: { type: 'object', properties: { message: { type: 'string', example: 'Logged out.' } }, }, ContactRequest: { type: 'object', required: ['message'], properties: { message: { type: 'string', maxLength: 5000, example: 'When does the shard launch?' }, email: { type: 'string', format: 'email', example: 'player@example.com' }, name: { type: 'string', maxLength: 100, example: 'Lord British' }, }, }, // Public discovery shape (GET /auth/providers) — enough for the login page // to render a button and start the flow. Never exposes secrets or endpoints. Provider: { type: 'object', properties: { id: { type: 'string', example: 'google' }, name: { type: 'string', example: 'Google' }, icon: { type: 'string', description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').", example: 'google', }, loginUrl: { type: 'string', description: 'Relative URL to begin the redirect flow.', example: '/api/v1/auth/sso/google/start', }, priority: { type: 'integer', description: 'Sort order (ascending).', example: 1 }, }, }, // Admin-facing provider config (GET/POST/PUT /admin/auth/providers). The // client secret is write-only and NEVER returned — `hasSecret` reports // whether one is stored. `builtin` marks google/discord (fixed kind/name), // and `health` is the config-completeness check used to gate visibility. ProviderConfig: { type: 'object', properties: { id: { type: 'string', example: 'okta' }, kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' }, name: { type: 'string', example: 'Okta' }, enabled: { type: 'boolean', example: true }, clientId: { type: 'string' }, hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true }, authorizeUrl: { type: 'string', format: 'uri' }, tokenUrl: { type: 'string', format: 'uri' }, userinfoUrl: { type: 'string', format: 'uri' }, scopes: { type: 'string', example: 'openid email profile' }, priority: { type: 'integer', example: 10 }, builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false }, health: { $ref: '#/components/schemas/ProviderHealth' }, }, }, ProviderHealth: { type: 'object', description: 'Config-completeness check that gates whether a provider is offered to end users.', properties: { valid: { type: 'boolean', example: true }, missing: { type: 'array', description: 'Names of required config fields that are still missing.', items: { type: 'string' }, example: [], }, }, }, ProviderCreateRequest: { type: 'object', required: ['id', 'kind', 'name'], properties: { id: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'okta' }, kind: { type: 'string', enum: ['oidc', 'oauth2'], example: 'oidc' }, name: { type: 'string', maxLength: 80, example: 'Okta' }, enabled: { type: 'boolean', example: true }, clientId: { type: 'string' }, secret: { type: 'string', format: 'password' }, authorizeUrl: { type: 'string', format: 'uri' }, tokenUrl: { type: 'string', format: 'uri' }, userinfoUrl: { type: 'string', format: 'uri' }, scopes: { type: 'string', maxLength: 500, example: 'openid email profile' }, priority: { type: 'integer', example: 10 }, }, }, Post: { type: 'object', properties: { id: { type: 'integer', example: 12 }, category: { type: 'string', example: 'news' }, title: { type: 'string', example: 'Server maintenance this weekend' }, slug: { type: 'string', example: 'server-maintenance-this-weekend' }, excerpt: { type: 'string', nullable: true }, body: { type: 'string', nullable: true }, image_url: { type: 'string', nullable: true, example: '/uploads/1700000000-abcd.png' }, published: { type: 'boolean', example: true }, author_id: { type: 'integer', nullable: true, example: 1 }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' }, published_at: { type: 'string', format: 'date-time', nullable: true }, }, }, PostCreateRequest: { type: 'object', required: ['category', 'title'], properties: { category: { type: 'string', example: 'news' }, title: { type: 'string', maxLength: 200, example: 'Server maintenance this weekend' }, body: { type: 'string' }, image_url: { type: 'string', description: 'Required for the screenshots category.' }, published: { type: 'boolean', example: false }, }, }, PublishRequest: { type: 'object', required: ['published'], properties: { published: { type: 'boolean', example: true } }, }, UploadResponse: { type: 'object', properties: { url: { type: 'string', example: '/uploads/1700000000-abcd.png' } }, }, WikiPage: { type: 'object', properties: { id: { type: 'integer', example: 3 }, slug: { type: 'string', example: 'getting-started' }, title: { type: 'string', example: 'Getting Started' }, excerpt: { type: 'string' }, body: { type: 'string' }, category_id: { type: 'integer', nullable: true, example: 2 }, published: { type: 'boolean', example: true }, tags: { type: 'array', items: { type: 'string' }, example: ['newbie', 'guide'] }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' }, }, }, WikiPageCreateRequest: { type: 'object', required: ['slug', 'title'], properties: { slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'getting-started' }, title: { type: 'string', maxLength: 200, example: 'Getting Started' }, excerpt: { type: 'string', maxLength: 400 }, body: { type: 'string' }, category_id: { type: 'integer', nullable: true }, published: { type: 'boolean', example: false }, tags: { type: 'array', items: { type: 'string' } }, }, }, WikiCategory: { type: 'object', properties: { id: { type: 'integer', example: 2 }, slug: { type: 'string', example: 'guides' }, title: { type: 'string', example: 'Guides' }, description: { type: 'string' }, sort_order: { type: 'integer', example: 0 }, }, }, WikiCategoryCreateRequest: { type: 'object', required: ['slug', 'title'], properties: { slug: { type: 'string', pattern: '^[a-z0-9-]+$', example: 'guides' }, title: { type: 'string', maxLength: 200, example: 'Guides' }, description: { type: 'string', maxLength: 400 }, sort_order: { type: 'integer', example: 0 }, }, }, User: { type: 'object', properties: { id: { type: 'integer', example: 1 }, username: { type: 'string', example: 'admin' }, role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'admin' }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, email: { type: 'string', format: 'email', nullable: true }, email_verified: { type: 'boolean', example: false }, totp_enabled: { type: 'boolean', example: true }, last_login_at: { type: 'string', format: 'date-time', nullable: true }, created_at: { type: 'string', format: 'date-time' }, }, }, UserCreateRequest: { type: 'object', required: ['username', 'password'], properties: { username: { type: 'string', minLength: 3, maxLength: 32, example: 'editor1' }, password: { type: 'string', format: 'password', minLength: 8, maxLength: 64 }, role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'editor' }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, email: { type: 'string', format: 'email', nullable: true }, }, }, // Player self-service credential changes (/api/v1/player/account/*). ChangeUsernameRequest: { type: 'object', required: ['username'], properties: { username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' }, }, }, ChangePasswordRequest: { type: 'object', required: ['newPassword'], properties: { newPassword: { type: 'string', format: 'password', minLength: 8, maxLength: 64 }, currentPassword: { type: 'string', format: 'password', description: 'Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password.', }, }, }, PlayerAccount: { type: 'object', description: 'Self-service player account (GET /player/account).', properties: { id: { type: 'integer', example: 42 }, username: { type: 'string', example: 'newplayer' }, role: { type: 'string', enum: ['player'], example: 'player' }, email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' }, status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' }, totp_enabled: { type: 'boolean', example: false }, has_password: { type: 'boolean', description: 'False for an SSO-provisioned account that has not set a password yet.', example: true, }, }, }, OkFlag: { type: 'object', properties: { ok: { type: 'boolean', example: true } }, }, TotpCodeRequest: { type: 'object', required: ['code'], properties: { code: { type: 'string', example: '123456' } }, }, SiteModeRequest: { type: 'object', required: ['mode'], properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } }, }, UnbanRequest: { type: 'object', required: ['ip'], properties: { ip: { type: 'string', example: '203.0.113.5' } }, }, // ── Actual mutation-response shapes ───────────────────────────────────── // These endpoints do NOT return the generic { message } envelope; they echo // the affected resource id/slug or a boolean flag. Documented here as-is so // the spec matches the controllers. (The shapes are intentionally recorded // rather than normalized — see the audit note if standardizing later.) AccountStatus: { type: 'object', description: 'Self-service account security status (GET /admin/account).', properties: { id: { type: 'integer', example: 1 }, username: { type: 'string', example: 'admin' }, role: { type: 'string', enum: ['admin', 'editor'], example: 'admin' }, totp_enabled: { type: 'boolean', example: true }, }, }, TotpSetup: { type: 'object', description: 'Enrollment material returned by POST /account/totp/setup.', properties: { otpauthUrl: { type: 'string', example: 'otpauth://totp/UOMysticmoon:admin?secret=...' }, qr: { type: 'string', description: 'QR code as a data: URL.', example: 'data:image/png;base64,iVBORw0KGgo...' }, }, }, TotpState: { type: 'object', description: 'Result of enabling/disabling 2FA.', properties: { totp_enabled: { type: 'boolean', example: true } }, }, LinkedIdentity: { type: 'object', properties: { provider: { type: 'string', example: 'google' }, email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' }, linked_at: { type: 'string', format: 'date-time' }, }, }, SiteModeState: { type: 'object', description: 'Result of PUT /admin/site-mode.', properties: { site_mode: { type: 'string', enum: ['live', 'maintenance'], example: 'maintenance' }, changed_at: { type: 'string', format: 'date-time' }, changed_by: { type: 'string', example: 'admin' }, }, }, PublicStatus: { type: 'object', description: 'Public site status (GET /public/status).', properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' }, status_message: { type: 'string', example: '' }, }, }, // Delete/mutation acknowledgements — each echoes the affected resource key // or a boolean flag rather than a { message } string. DeletedId: { type: 'object', properties: { id: { type: 'integer', example: 12 } }, }, DeletedSlug: { type: 'object', properties: { slug: { type: 'string', example: 'getting-started' } }, }, DeletedFlag: { type: 'object', properties: { deleted: { type: 'boolean', example: true } }, }, UnlinkedFlag: { type: 'object', properties: { unlinked: { type: 'boolean', example: true } }, }, UnbanResult: { type: 'object', properties: { ip: { type: 'string', example: '203.0.113.5' }, removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, // ── uo-link shard data ────────────────────────────────────────────── ShardStatus: { type: 'object', description: 'Public shard status (GET /public/shard/status).', properties: { enabled: { type: 'boolean', example: true }, status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' }, pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true }, lastEventAt: { type: 'string', format: 'date-time', nullable: true }, onlineCount: { type: 'integer', example: 12 }, economy: { $ref: '#/components/schemas/ShardEconomyPoint' }, }, }, ShardEvent: { type: 'object', description: 'A logged shard event.', properties: { id: { type: 'integer', example: 4821 }, kind: { type: 'string', example: 'vendor.sale' }, t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 }, bootId: { type: 'string', nullable: true, example: 'boot-abc123' }, payload: { type: 'object', additionalProperties: true, description: 'The full event object.' }, createdAt: { type: 'string', format: 'date-time' }, }, }, ShardEconomyPoint: { type: 'object', nullable: true, description: 'One gold-supply sample.', properties: { accounts: { type: 'integer', nullable: true, example: 240 }, gold: { type: 'integer', nullable: true, example: 1028983421 }, t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 }, }, }, ShardHouse: { type: 'object', description: 'A house at its current decay stage.', properties: { serial: { type: 'string', example: '0x4004705F' }, stage: { type: 'string', example: 'IDOC' }, map: { type: 'string', nullable: true, example: 'Trammel' }, x: { type: 'integer', nullable: true }, y: { type: 'integer', nullable: true }, z: { type: 'integer', nullable: true }, region: { type: 'string', nullable: true }, name: { type: 'string', nullable: true, example: 'An Unnamed House' }, ownerSerial: { type: 'string', nullable: true }, ownerAcct: { type: 'string', nullable: true }, builtOn: { type: 'string', format: 'date-time', nullable: true }, lastRefreshed: { type: 'string', format: 'date-time', nullable: true }, isIdoc: { type: 'boolean', example: true }, updatedAt: { type: 'string', format: 'date-time' }, }, }, }, }, } swaggerAutogen(outputFile, routes, doc).then(() => { // eslint-disable-next-line no-console console.log('swagger-output.json generated.') })