// ── 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 fs = require('fs') const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) const pkg = require('../package.json') const brand = require('../src/config/brand') // Cookie name is env-configurable (COOKIE_NAME); the spec documents whatever this // build targets. This is a build-time artifact — regenerate with `npm run swagger`. const COOKIE_NAME = process.env.COOKIE_NAME || 'rg_token' 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: `${brand.name} API`, version: pkg.version, description: `REST API for the ${brand.name} website, wiki and admin panel.\n\n` + 'This document is core. Installed modules add their own paths, tags and ' + 'schemas to it at request time from the fragment each one ships, so ' + '`/api/docs.json` on a running instance describes more than `npm run swagger` ' + 'generates here (docs/website/MODULE_API.md §6.1a).\n\n' + '### Authentication\n' + `- **Web / admin panel** uses an httpOnly session cookie (\`${COOKIE_NAME}\`) 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' }, ], // Core's tags only. A module contributes its own in its fragment, and they are // merged in beside these — the four game-specific ones that used to sit here // (`Public · Shard`, `Public · Atlas`, `Player · Shard`, `Admin · Shard`) went // with the routes they group, and arrive back from module-uo on any instance // that has it installed. tags: [ { name: 'Health', description: 'Liveness probe' }, { name: 'Auth', description: 'Web session login/logout (cookie + TOTP)' }, { name: 'Auth · Me', description: 'The signed-in account: profile, notification streams and devices' }, { name: 'Auth · Mobile', description: 'Native bearer-token login, refresh and logout' }, { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Player · Appeals', description: 'Player-submitted moderation appeals' }, { name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' }, { name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' }, { name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' }, { name: 'Admin · Pages', description: 'Editable static site pages' }, { name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' }, { name: 'Admin · Settings', description: 'Site settings (admin only)' }, { name: 'Admin · Email', description: 'Outbound email configuration and delivery test (admin only)' }, { name: 'Admin · Invites', description: 'Registration invites — issue, list and revoke' }, { name: 'Admin · Moderation', description: 'Player reports, appeals and moderator actions' }, { 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: COOKIE_NAME, 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'], description: 'Second step for 2FA login. Supply either code OR recoveryCode.', properties: { challenge: { type: 'string', description: 'Token returned by /login when totpRequired.' }, code: { type: 'string', description: 'Current authenticator code.', example: '123456' }, recoveryCode: { type: 'string', description: 'A single-use recovery code (alternative to code).', example: 'abcde-12345' }, trustDevice: { type: 'boolean', description: 'Remember this browser so future logins skip the TOTP step (30 days).', example: false }, deviceName: { type: 'string', description: 'Optional friendly label for the Trusted Devices list.', example: 'My Laptop' }, }, }, 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' }, recoveryCode: { type: 'string', description: 'Single-use recovery code (alternative to code).', example: 'abcde-12345' }, trustDevice: { type: 'boolean', description: 'Remember this device so future logins skip the TOTP step; the response then carries trustToken.', example: false }, device_name: { type: 'string', description: 'Optional friendly device label for Active/Trusted Devices.', example: 'Pixel 8' }, }, }, 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' }, trustToken: { type: 'string', nullable: true, description: 'Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP.' }, trustLimitReached: { type: 'boolean', nullable: true, description: 'Present (true) when trustDevice was requested but the device cap is reached; see devices.' }, devices: { type: 'array', nullable: true, items: { $ref: '#/components/schemas/TrustedDevice' }, description: 'The existing trusted devices, when trustLimitReached is set.' }, }, }, 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 }, }, }, MobileSsoExchangeRequest: { type: 'object', required: ['code', 'code_verifier'], properties: { code: { type: 'string', description: 'The single-use authorization code returned to the app callback.', }, code_verifier: { type: 'string', description: 'The PKCE verifier for the challenge sent to /auth/mobile/sso/start.', }, device_name: { type: 'string', description: 'Optional friendly device label for Active Devices.', example: 'Pixel 8' }, }, }, DeviceSession: { type: 'object', properties: { id: { type: 'integer', description: 'Session row id (pass to DELETE /auth/me/sessions/:id).' }, deviceName: { type: 'string', nullable: true, example: 'Pixel 8' }, userAgent: { type: 'string', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, lastUsedAt: { type: 'string', format: 'date-time' }, expiresAt: { type: 'string', format: 'date-time' }, }, }, // A device allowed to skip the TOTP step at login (MFA "Trust this device"). // Distinct from DeviceSession (a live mobile login session). Never exposes the // trust token/hash. TrustedDevice: { type: 'object', properties: { id: { type: 'integer', description: 'Trusted-device id (pass to DELETE …/trusted-devices/:id).' }, platform: { type: 'string', enum: ['web', 'mobile'], example: 'web' }, deviceName: { type: 'string', nullable: true, example: 'My Laptop' }, userAgent: { type: 'string', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, lastUsedAt: { type: 'string', format: 'date-time' }, expiresAt: { type: 'string', format: 'date-time' }, }, }, // Result of POST /auth/me/trusted-devices. Web receives an httpOnly cookie and // { trusted:true }; native (bearer) sessions additionally get { trustToken }. TrustDeviceResult: { type: 'object', properties: { trusted: { type: 'boolean', example: true }, trustToken: { type: 'string', nullable: true, description: 'Native clients only — store securely and send as X-Trust-Token.' }, }, }, // 409 body when the trusted-device cap is reached: the caller must revoke one // of the listed devices before retrying. TrustedDeviceLimit: { type: 'object', properties: { error: { type: 'string', example: 'trusted_device_limit' }, devices: { type: 'array', items: { $ref: '#/components/schemas/TrustedDevice' } }, }, }, // One-time recovery (backup) codes. Returned ONLY at generation; never re-shown. RecoveryCodes: { type: 'object', properties: { recoveryCodes: { type: 'array', items: { type: 'string', example: 'abcde-12345' } }, }, }, 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 } }, }, // ── Push notifications (M7) ───────────────────────────────────────────── RegisterDeviceRequest: { type: 'object', required: ['endpoint'], properties: { endpoint: { type: 'string', format: 'uri', description: 'The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected.', example: 'https://ntfy.example.com/UP0a1b2c3d4e5f', }, transport: { type: 'string', enum: ['unifiedpush', 'fcm'], default: 'unifiedpush', example: 'unifiedpush' }, platform: { type: 'string', nullable: true, maxLength: 40, example: 'android' }, }, }, PushDevice: { type: 'object', properties: { id: { type: 'integer', example: 7 }, transport: { type: 'string', enum: ['unifiedpush', 'fcm'], example: 'unifiedpush' }, endpoint: { type: 'string', example: 'https://ntfy.example.com/UP0a1b2c3d4e5f' }, platform: { type: 'string', nullable: true, example: 'android' }, createdAt: { type: 'string', format: 'date-time' }, lastSeenAt: { type: 'string', format: 'date-time' }, }, }, NotificationStream: { type: 'object', description: 'One subscribable push stream from the catalog.', properties: { id: { type: 'string', example: 'idoc.warning' }, label: { type: 'string', example: 'IDOC warnings' }, description: { type: 'string', example: 'A house falls into its final (IDOC) decay stage.' }, personal: { type: 'boolean', description: 'Owner-keyed — delivered only to the owning user, never fanned out publicly.', example: false, }, requiresLinkedAccount: { type: 'boolean', description: 'The stream needs a linked game account (personal streams).', example: false, }, }, }, NotificationStreams: { type: 'object', properties: { streams: { type: 'array', items: { $ref: '#/components/schemas/NotificationStream' } }, }, }, NotificationSubscriptions: { type: 'object', description: 'The set of stream ids the user has opted into (used for both GET and PUT).', properties: { streams: { type: 'array', items: { type: 'string' }, example: ['news.post', 'idoc.warning', 'vendor.sale'], }, }, }, TeamNotificationPref: { type: 'object', description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).", properties: { teamId: { type: 'integer', example: 3 }, slug: { type: 'string', example: 'the-silver-hand' }, name: { type: 'string', example: 'The Silver Hand' }, archived: { type: 'boolean', example: false }, muted: { type: 'boolean', example: false }, emailMode: { type: 'string', enum: ['off', 'digest', 'immediate'], example: 'off' }, }, }, TeamNotificationPrefs: { type: 'object', description: 'Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty.', properties: { teams: { type: 'array', items: { $ref: '#/components/schemas/TeamNotificationPref' } }, }, }, // ── Moderation appeals (Phase 6c/6d) ──────────────────────────────────── Appeal: { type: 'object', description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).', properties: { id: { type: 'integer', example: 12 }, mod_action_id: { type: 'integer', example: 340 }, discord_user_id: { type: 'string', example: '216734083584917504' }, action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' }, user_id: { type: 'integer', nullable: true, example: 42 }, status: { type: 'string', enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'], example: 'pending', }, submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' }, staff_response: { type: 'string', nullable: true, example: null }, handled_by_user_id: { type: 'integer', nullable: true, example: null }, handled_by_tag: { type: 'string', nullable: true, example: null }, reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.', example: 'none', }, submitted_at: { type: 'string', format: 'date-time' }, resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null }, action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' }, action_reason: { type: 'string', nullable: true, example: 'Spam' }, action_created_at: { type: 'string', format: 'date-time', nullable: true }, action_duration_seconds: { type: 'integer', nullable: true, example: 86400 }, submitter_username: { type: 'string', nullable: true, example: 'newplayer' }, }, }, ContentReport: { type: 'object', description: 'A member-raised report about a piece of content (TEAMS.md §5.6). ' + 'Generic by design: `targetType` is a string rather than an enum in the schema ' + 'because a wiki page or a news comment is meant to become a new value here, not a new queue. ' + 'Reports reach SITE STAFF only — there is no leader-facing view of this queue, ' + 'because a Team\'s leaders are exactly the people who will not report their own Team.', properties: { id: { type: 'integer', example: 41 }, targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' }, targetId: { type: 'integer', example: 812 }, teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' }, reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' }, reporterDeleted: { type: 'boolean', example: false }, reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' }, detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' }, status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' }, handledBy: { type: 'string', nullable: true, example: 'moderator1' }, handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' }, handledAt: { type: 'string', format: 'date-time', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, target: { type: 'object', nullable: true, description: 'The reported content, already resolved so triage never means hunting. ' + 'NULL when the target has since been hard-deleted — the report still lists, because ' + '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. ' + 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).', properties: { kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' }, threadId: { type: 'integer', nullable: true, example: 19 }, threadTitle: { type: 'string', nullable: true, example: 'Raid night' }, postId: { type: 'integer', nullable: true, example: 812 }, uploadId: { type: 'integer', nullable: true }, title: { type: 'string', nullable: true }, type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] }, author: { type: 'string', nullable: true, example: 'someone' }, uploader: { type: 'string', nullable: true }, excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' }, status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] }, filename: { type: 'string', nullable: true }, url: { type: 'string', nullable: true, example: '/uploads/a1b2c3.png' }, mimetype: { type: 'string', nullable: true, example: 'image/png', description: 'The sniffed type, never the client\'s header.' }, byteSize: { type: 'integer', nullable: true, example: 184320 }, deleted: { type: 'boolean', nullable: true }, createdAt: { type: 'string', format: 'date-time', nullable: true }, }, }, }, }, AppealQueueItem: { allOf: [{ $ref: '#/components/schemas/Appeal' }], description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.', }, AppealResolveResult: { allOf: [ { $ref: '#/components/schemas/Appeal' }, { type: 'object', properties: { reversal: { type: 'object', description: 'What the approval attempted against Discord.', properties: { attempted: { type: 'boolean', example: true }, ok: { type: 'boolean', example: true }, reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' }, bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' }, error: { type: 'string', nullable: true, example: null }, }, }, }, }, ], }, AppealEligibleAction: { type: 'object', description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).', properties: { id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' }, action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' }, target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' }, reason: { type: 'string', nullable: true, example: 'Spam' }, duration_seconds: { type: 'integer', nullable: true, example: 86400 }, created_at: { type: 'string', format: 'date-time' }, }, }, CreateAppealRequest: { type: 'object', required: ['mod_action_id', 'submitted_text'], properties: { mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' }, submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' }, }, }, ResolveAppealRequest: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' }, staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' }, }, }, 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/${brand.name}: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. Enabling also returns the one-time recovery codes.', properties: { totp_enabled: { type: 'boolean', example: true }, recoveryCodes: { type: 'array', nullable: true, description: 'Single-use recovery codes, shown ONCE on enable.', items: { type: 'string', example: 'abcde-12345' }, }, }, }, 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: '' }, version: { $ref: '#/components/schemas/PublicVersion' }, }, }, PublicVersion: { type: 'object', description: 'Backend identity + version (GET /public/version; also embedded in /public/status).', properties: { service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' }, api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' }, server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' }, }, }, PublicModules: { type: 'object', description: 'Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated.', properties: { modules: { type: 'array', items: { $ref: '#/components/schemas/PublicModule' }, }, }, }, PublicModule: { type: 'object', description: 'One started module, as published to anonymous clients.', properties: { id: { type: 'string', example: 'uo', description: 'Module id — also the URL segment its routes live under (/api/v1/public/).' }, name: { type: 'string', example: 'Ultima Online', description: 'Human label.' }, version: { type: 'string', example: '1.0.0', description: 'The module\'s own version (semver). Unrelated to the API version.' }, capabilities: { type: 'array', description: 'Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent.', items: { type: 'string', example: 'shard' }, }, }, }, Brand: { type: 'object', description: 'Per-shard branding (BRAND_* env, with admin overrides for name/contactEmail). A client themes itself from this — one instance runs as any shard. Asset fields (logo/hero/favicon) may be site-relative paths; resolve them against the site base URL.', properties: { name: { type: 'string', example: 'Runic Gateway' }, shortName: { type: 'string', example: 'Runic Gateway' }, tagline: { type: 'string', example: 'an independent private Ultima Online shard' }, description: { type: 'string' }, contactEmail: { type: 'string', example: '' }, url: { type: 'string', example: '' }, accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.', }, logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' }, hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' }, favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' }, }, }, PublicSettings: { type: 'object', description: 'Public site settings + branding (GET /public/settings). Whitelisted string settings, plus derived availability flags and the brand block a client themes from. Additional whitelisted keys may appear.', properties: { site_title: { type: 'string', example: 'Runic Gateway' }, status_message: { type: 'string', example: '' }, maintenance_message: { type: 'string', example: '' }, registration: { type: 'object', properties: { password: { type: 'boolean' }, sso: { type: 'boolean' } }, }, brand: { $ref: '#/components/schemas/Brand' }, theme: { type: 'object', nullable: true, description: 'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).', additionalProperties: { type: 'string' }, example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' }, }, push: { type: 'object', description: 'Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard.', properties: { ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' }, }, }, }, additionalProperties: true, }, NavSettings: { type: 'object', description: 'Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav.', properties: { nav_admin: { type: 'string', nullable: true, example: '{"/admin/posts":{"label":"Blog Posts","order":10}}', }, nav_player: { type: 'string', nullable: true, example: null }, }, }, ThemeOptions: { type: 'object', description: 'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.', properties: { presets: { type: 'array', description: 'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".', items: { type: 'object', properties: { id: { type: 'string', example: 'fantasy' }, label: { type: 'string', example: 'Fantasy' }, tokens: { type: 'object', nullable: true, additionalProperties: { type: 'string' }, example: { '--bg': '#1a120b', '--accent': '#c9973f' }, }, }, }, }, colorFields: { type: 'array', description: 'Editable color fields, each paired with the CSS variable it drives.', items: { type: 'object', properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } }, }, }, radiusFields: { type: 'array', items: { type: 'object', properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } }, }, }, shippedTokens: { type: 'object', description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.', additionalProperties: { type: 'string' }, }, fonts: { type: 'object', description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.', additionalProperties: { type: 'array', items: { type: 'object', properties: { value: { type: 'string' }, label: { type: 'string' } }, }, }, }, shadows: { type: 'array', items: { type: 'object', properties: { value: { type: 'string' }, label: { type: 'string' } }, }, }, radiusMaxPx: { type: 'integer', example: 999 }, }, }, // 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 }, }, }, // ── Teams (docs/website/TEAMS.md) ──────────────────────────────────── OkResponse: { type: 'object', properties: { ok: { type: 'boolean', example: true } }, }, TeamSyncFreshness: { type: 'object', description: 'Freshness of core\'s projection of the game\'s Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one.', properties: { configured: { type: 'boolean', example: true }, stale: { type: 'boolean', description: 'True past twice the reconcile interval, or when the projection has never synced at all.', example: false, }, lastSyncAt: { type: 'string', format: 'date-time', nullable: true }, consecutiveFailures: { type: 'integer', example: 0 }, }, }, PublicTeam: { type: 'object', description: 'A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it.', properties: { slug: { type: 'string', example: 'the-silver-hand' }, name: { type: 'string', example: 'The Silver Hand' }, abbr: { type: 'string', nullable: true, example: 'TSH' }, memberCount: { type: 'integer', example: 42 }, linkedCount: { type: 'integer', description: 'Members with a linked site account.', example: 11 }, onlineCount: { type: 'integer', example: 3 }, meta: { type: 'object', nullable: true, additionalProperties: true, description: 'Module-supplied and opaque to core.' }, status: { type: 'string', enum: ['active', 'archived'] }, createdAt: { type: 'string', format: 'date-time' }, rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, archivedAt: { type: 'string', format: 'date-time', nullable: true }, archivedReason: { type: 'string', nullable: true, example: 'renamed' }, successor: { type: 'object', nullable: true, description: 'Where an archived Team continued after a rename, so an old link explains itself instead of 404ing.', properties: { slug: { type: 'string' }, name: { type: 'string' } }, }, }, }, PublicTeamList: { type: 'object', allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], properties: { teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } }, total: { type: 'integer', example: 12 }, enabled: { type: 'boolean', description: 'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.', example: true, }, }, }, PublicTeamMember: { type: 'object', description: 'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.', properties: { displayName: { type: 'string', nullable: true, example: 'Aldric' }, rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' }, isLeader: { type: 'boolean', example: true }, online: { type: 'boolean', example: false }, linked: { type: 'boolean', example: true }, }, }, PublicTeamRoster: { type: 'object', allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], properties: { members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } }, rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, projected: { type: 'boolean', description: 'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.', example: true, }, }, }, PublicTeamActivityItem: { type: 'object', description: '`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.', properties: { id: { type: 'integer', example: 4821 }, source: { type: 'string', description: '`core` or a module id.', example: 'uo' }, kind: { type: 'string', example: 'uo.champion.completed' }, summary: { type: 'string', example: 'Completed Champion Neira' }, visibility: { type: 'string', enum: ['public', 'members'] }, occurredAt: { type: 'string', format: 'date-time' }, payload: { type: 'object', nullable: true, additionalProperties: true }, }, }, PublicTeamActivity: { type: 'object', properties: { items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } }, total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 }, limit: { type: 'integer', example: 50 }, offset: { type: 'integer', example: 0 }, scope: { type: 'string', enum: ['public', 'members'], description: 'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.', }, }, }, PlayerTeamList: { type: 'object', allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], properties: { teams: { type: 'array', items: { allOf: [{ $ref: '#/components/schemas/PublicTeam' }], type: 'object', properties: { reason: { type: 'string', enum: ['membership', 'grant', 'both'], description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.', }, isLeader: { type: 'boolean' }, }, }, }, }, }, PlayerTeamAccess: { type: 'object', properties: { slug: { type: 'string' }, allowed: { type: 'boolean' }, viaMembership: { type: 'boolean' }, viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' }, isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' }, }, }, AdminTeam: { type: 'object', description: 'The full staff view, including what a staff decision overrode.', properties: { id: { type: 'integer' }, moduleId: { type: 'string', example: 'uo' }, externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' }, slug: { type: 'string' }, name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' }, displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' }, displayNameOverride: { type: 'string', nullable: true }, abbr: { type: 'string', nullable: true }, status: { type: 'string', enum: ['active', 'archived'] }, hidden: { type: 'boolean' }, hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] }, hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' }, nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' }, memberCount: { type: 'integer' }, linkedCount: { type: 'integer' }, onlineCount: { type: 'integer' }, rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' }, succeededBy: { type: 'integer', nullable: true }, createdAt: { type: 'string', format: 'date-time' }, archivedAt: { type: 'string', format: 'date-time', nullable: true }, archivedReason: { type: 'string', nullable: true }, meta: { type: 'object', nullable: true, additionalProperties: true }, members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } }, grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } }, pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } }, }, }, AdminTeamMember: { type: 'object', properties: { memberKey: { type: 'string', example: '0x40012ab3' }, displayName: { type: 'string', nullable: true }, userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' }, rankLabel: { type: 'string', nullable: true }, isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' }, isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' }, leaderOverride: { type: 'object', nullable: true, properties: { effect: { type: 'string', enum: ['grant', 'deny'] }, reason: { type: 'string', nullable: true }, by: { type: 'string', nullable: true }, at: { type: 'string', format: 'date-time' }, }, }, online: { type: 'boolean' }, status: { type: 'string', enum: ['active', 'departed'] }, firstSeenAt: { type: 'string', format: 'date-time' }, lastSeenAt: { type: 'string', format: 'date-time' }, departedAt: { type: 'string', format: 'date-time', nullable: true }, }, }, AdminTeamList: { type: 'object', allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], properties: { teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } }, syncState: { type: 'object', nullable: true, description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.', properties: { moduleId: { type: 'string' }, lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, lastSuccessAt: { type: 'string', format: 'date-time', nullable: true }, consecutiveFailures: { type: 'integer' }, lastError: { type: 'string', nullable: true }, pendingEmptySince: { type: 'string', format: 'date-time', nullable: true }, }, }, }, }, TeamGrant: { type: 'object', description: 'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.', properties: { id: { type: 'integer' }, team_id: { type: 'integer' }, user_id: { type: 'integer', nullable: true }, username: { type: 'string', nullable: true }, granted_by: { type: 'integer', nullable: true }, granted_username: { type: 'string', nullable: true }, granted_at: { type: 'string', format: 'date-time' }, reason: { type: 'string', nullable: true }, revoked_by: { type: 'integer', nullable: true }, revoked_username: { type: 'string', nullable: true }, revoked_at: { type: 'string', format: 'date-time', nullable: true }, revoke_reason: { type: 'string', nullable: true }, }, }, TeamGrantLedger: { type: 'object', properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } }, }, TeamModerationRequest: { type: 'object', properties: { id: { type: 'integer' }, team_id: { type: 'integer' }, team_name: { type: 'string' }, team_slug: { type: 'string' }, action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] }, payload: { type: 'object', nullable: true, additionalProperties: true }, reason: { type: 'string', nullable: true }, requested_by: { type: 'integer', nullable: true }, requested_username: { type: 'string', nullable: true }, requested_at: { type: 'string', format: 'date-time' }, status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] }, decided_by: { type: 'integer', nullable: true }, decided_username: { type: 'string', nullable: true }, decided_at: { type: 'string', format: 'date-time', nullable: true }, decision_note: { type: 'string', nullable: true }, }, }, TeamRequestQueue: { type: 'object', properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } }, }, TeamReviewQueue: { type: 'object', description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.', properties: { teams: { type: 'array', items: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string', example: 'Admin' }, slug: { type: 'string' }, hidden_term: { type: 'string', example: 'admin' }, display_name_override: { type: 'string', nullable: true }, member_count: { type: 'integer' }, created_at: { type: 'string', format: 'date-time' }, }, }, }, }, }, TeamIntegrationRow: { type: 'object', description: 'One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits.', properties: { id: { type: 'integer' }, platform: { type: 'string', example: 'discord' }, team_id: { type: 'integer', nullable: true }, team_name: { type: 'string', nullable: true }, events: { type: 'array', items: { type: 'string', example: 'team.announcement' } }, channel_ref: { type: 'string', nullable: true, example: '1024839201048392010' }, enabled: { type: 'boolean' }, members_ack: { type: 'boolean', description: 'The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes.', }, members_ack_by: { type: 'integer', nullable: true }, members_ack_username: { type: 'string', nullable: true }, members_ack_at: { type: 'string', format: 'date-time', nullable: true }, updated_at: { type: 'string', format: 'date-time' }, }, }, TeamIntegrationConfig: { type: 'object', properties: { platform: { type: 'string', example: 'discord' }, events: { type: 'array', description: 'Every event that may be bridged, and whether it carries members-only content.', items: { type: 'object', properties: { id: { type: 'string', example: 'team.forum.post' }, membersOnly: { type: 'boolean' }, }, }, }, rows: { type: 'array', items: { $ref: '#/components/schemas/TeamIntegrationRow' } }, }, }, TeamModerationResult: { type: 'object', description: 'The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin\'s call applies at once and reports false.', properties: { ok: { type: 'boolean' }, pending: { type: 'boolean', example: false }, requestId: { type: 'integer', nullable: true }, }, }, TeamResyncResult: { type: 'object', description: 'A reconciliation outcome. `ok: false` carries the provider\'s own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied.', properties: { ok: { type: 'boolean' }, reason: { type: 'string', nullable: true }, quarantined: { type: 'boolean', nullable: true }, created: { type: 'integer', nullable: true }, renamed: { type: 'integer', nullable: true }, archived: { type: 'integer', nullable: true }, rosters: { type: 'integer', nullable: true, description: 'Rosters actually applied; a refused one is left untouched and not counted.' }, rehidden: { type: 'integer', nullable: true }, }, }, TeamReasonRequest: { type: 'object', properties: { reason: { type: 'string', maxLength: 255, example: 'impersonates staff' } }, }, TeamDisplayNameRequest: { type: 'object', properties: { displayName: { type: 'string', nullable: true, maxLength: 160, description: 'Empty or null clears the override.', example: 'The Old Guard' }, reason: { type: 'string', maxLength: 255 }, }, }, TeamLeaderOverrideRequest: { type: 'object', required: ['memberKey', 'effect'], properties: { memberKey: { type: 'string', maxLength: 191, example: '0x40012ab3' }, effect: { type: 'string', enum: ['grant', 'deny'] }, reason: { type: 'string', maxLength: 255 }, }, }, TeamDecideRequest: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['approved', 'rejected'] }, note: { type: 'string', maxLength: 255 }, }, }, }, }, } /** * 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 } // Static analysis cannot follow a route into an extension slot, so the slot // routers contribute a generated fragment afterwards — see swagger/slotSpecs.js // for what goes wrong without it. Merged BEFORE normalizePaths, so the merged-in // paths are sorted and trailing-slash-checked with everything else. // // The pool is pointed at a closed port here for the same reason // scripts/routeManifest.js does it: the merge step requires src/app.js to find // where each slot router is mounted, and requiring app.js builds the models. No // query is ever run. process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1' process.env.DB_PORT = process.env.DB_PORT || '59999' // ── An annotation swagger-autogen cannot parse is DROPPED, not failed ────── // // It `console.error`s "Syntax error" or "out of structure", skips that one // annotation, and prints `Success` in green. Nothing was listening, so the tree // had been carrying a broken one — `POST /api/v1/admin/invites` documented with an // EMPTY request body — for as long as it had existed. The same class turned up // four more times in module-uo, whose annotations came from here. // // Two ways one breaks, both of them invisible in review: an object literal a // brace short, and a `"` or a backtick inside a single-quoted description // (swagger-autogen re-quotes both to `'` before evaluating, which ends the string // early). Capturing the diagnostics is the only way to be told. const swaggerComplaints = [] const realConsoleError = console.error console.error = (...args) => { const line = args.map(String).join(' ') if (/syntax error|out of structure/i.test(line)) swaggerComplaints.push(line.trim()) else realConsoleError(...args) } /* eslint-disable global-require */ swaggerAutogen(outputFile, routes, doc) .then(() => { console.error = realConsoleError if (swaggerComplaints.length > 0) { throw new Error( `swagger: ${swaggerComplaints.length} annotation(s) could not be parsed and were DROPPED ` + `— the spec would be missing what they described:\n ${swaggerComplaints.join('\n ')}`, ) } }) .then(() => require('./slotSpecs').mergeSlotSpecs(outputFile)) .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.') // The mariadb pool keeps the loop alive even pointed at a dead port. return require('../src/utils/db').close() }) .catch((err) => { console.error = realConsoleError process.stderr.write(`${err.stack || err.message}\n`) process.exit(1) }) /* eslint-enable global-require */