TEAMS.md §5.6. **Core has had no user-facing report flow of any kind** — the `moderation`, `mod_notes` and `appeals` tables are all either staff-initiated or Discord-sanction-shaped, and nothing anywhere let a member say "this is a problem". That was survivable while every piece of content on the site came from staff; phase 5 lets players write to each other, so it stops being. The gap has a specific shape: leaders moderate their own Team's forum, and a Team's leaders are exactly the people who will not report their own Team. So the whole point of this queue is a path that routes AROUND a Team's own leadership. Org lead settled it on 2026-08-18: **reports are site administration only** — there is no leader-facing view of this queue, not even a read-only one scoped to their own Team. §5.6's "a leader may also see and act on reports for their own Team" is not implemented and is not deferred. `content_reports` is deliberately generic — `target_type` is a VARCHAR so a wiki page or a news comment becomes a value rather than a table — and the queue is mounted beside appeals under /admin/moderation rather than under Teams, because a staffer working a queue should have one place to work. **§5.6's literal unique key has a defect and this does not copy it.** Written as (target_type, target_id, reporter_user_id, status) it makes CLOSED rows collide with each other too: reporter reports a post, staff dismiss it, the behaviour recurs, they report again — and the second dismissal is an UPDATE into a tuple that already exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The key is on a generated `open_marker` instead, the same trick `team_forum_grants.active_marker` uses: 1 while open, NULL once closed, and NULLs are distinct — which is what §5.6's prose asks for, "one open report per (target, reporter)". Two other departures from the doc, both small and both flagged in the docs PR: `handled_note`, because a queue whose resolution reason lives only in an activity_log line is one where the next staffer to see a repeat report cannot find out why the last was dismissed; and a CASCADE on `team_id`, so a deleted Team does not leave a queue full of reports about content that no longer exists. Also here: a report is filed against a target the model verifies really belongs to the Team the request came through, or the queue's per-Team filter would quietly be lying; the queue resolves every row's target in three batched reads rather than N+1, which is §5.6's fourth rule (uploader, size and sniffed type without hunting) actually paying for §5.5.4's attribution table; a target that has since been hard-deleted comes back null and the report still lists, because "somebody reported this and by the time we looked it was gone" is a fact a moderator needs; and every transition writes activity_log, `dismissed` included — a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace. `teams_forum_edit_window_minutes` gains its range validation on the admin settings PUT and is seeded at 15, so the value on the settings screen is the value in force. Route manifest and OpenAPI regenerated: 6 operations added, 0 lost. Co-Authored-By: Claude <noreply@anthropic.com>
1424 lines
70 KiB
JavaScript
1424 lines
70 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' },
|
||
},
|
||
},
|
||
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/<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 },
|
||
},
|
||
},
|
||
|
||
// ── 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' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
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 */
|