Files
website/server/swagger/swagger.js
Claude f8db61025b Audit and fix Swagger/OpenAPI accuracy; regenerate served spec
The route-level annotations were 100% present, but the committed/served
spec (swagger-output.json) was stale and several response schemas had
drifted from the controllers. This aligns the docs with actual behavior
and regenerates the spec.

Served spec was stale (64/67 operations). Regenerating picks up three
routes that were added after the last generation:
  - POST /api/v1/auth/sso/totp
  - GET  /api/v1/admin/discord-bot/config
  - PUT  /api/v1/admin/discord-bot/config
plus a stale /auth/logout summary.

Response-shape corrections (annotation now matches controller output):
  - Mutation endpoints do NOT return the generic { message } envelope.
    Deletes echo { id } / { slug }; toggles return { deleted },
    { unlinked }, { totp_enabled }, or { ip, removed }. Documented as-is
    via new DeletedId/DeletedSlug/DeletedFlag/UnlinkedFlag/TotpState/
    UnbanResult components. (The API is intentionally inconsistent here;
    recorded rather than normalized — see follow-up note.)
  - POST /account/totp/setup: otpauth_url -> otpauthUrl (TotpSetup)
  - PUT  /admin/site-mode: { mode } -> { site_mode, changed_at, changed_by }
  - GET  /account: full User -> AccountStatus (id/username/role/totp_enabled)
  - GET  /account/identities: add linked_at (LinkedIdentity)
  - GET  /public/status: add status_message (PublicStatus)
  - POST /auth/sso/totp: user is SafeUser, not full User
  - GET  /dashboard: description/shape corrected (posts+users, no wiki)

Schema completeness:
  - Provider (public discovery): { id, name, icon, loginUrl, priority },
    not { id, name, kind }
  - ProviderConfig: add hasSecret, builtin, health (ProviderHealth)
  - Post: add excerpt, author_id, published_at
  - MobileTokenResponse.expiresIn: duration string ("15m"), not integer

Config: declare the Admin · Discord Bot tag (was used but undeclared).

Auth model and the internal/external boundary were verified correct and
left unchanged: cookie + bearer are both accepted on session routes (dual
security annotations are accurate), and /internal/* runs on a separate
listener already excluded from the scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
2026-07-04 22:51:16 -05:00

459 lines
20 KiB
JavaScript

// ── 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: 'Admin · Account', description: 'Self-service account security (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 <accessToken>.
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: '' },
},
},
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'], example: 'admin' },
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'], example: 'editor' },
},
},
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 },
},
},
},
},
}
swaggerAutogen(outputFile, routes, doc).then(() => {
// eslint-disable-next-line no-console
console.log('swagger-output.json generated.')
})