Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.
**The merge (MODULE_API.md §6.1a).** `swagger-output.json` is core's own routes
and cannot be anything else — it is generated on a developer's machine and
committed, so it must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.
Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.
Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.
**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):
- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
not served since slice 1 — 578 lines. They moved to module-uo, namespaced
`Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
`## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
module, not by core, and documented in the module's README instead.
**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.
Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.
- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
(128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
/api/docs renders the module's operations with zero console errors
Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1
Co-Authored-By: Claude <noreply@anthropic.com>
1029 lines
49 KiB
JavaScript
1029 lines
49 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 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 <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: '' },
|
|
},
|
|
},
|
|
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'],
|
|
},
|
|
},
|
|
},
|
|
// ── 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' },
|
|
},
|
|
},
|
|
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/<id-owned prefixes>).' },
|
|
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 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
/**
|
|
* 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 */
|