The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1954 lines
95 KiB
JavaScript
1954 lines
95 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, account security (credentials, 2FA, linked identities, recovery codes), 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: '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.',
|
||
},
|
||
},
|
||
},
|
||
// The self account as GET /auth/me/account returns it, for EVERY role — the
|
||
// name predates the collapse of /player/account and /admin/account onto
|
||
// /auth/me and is kept so existing $refs and generated clients resolve.
|
||
PlayerAccount: {
|
||
type: 'object',
|
||
description: 'The signed-in account (GET /auth/me/account). Same shape for every role.',
|
||
properties: {
|
||
id: { type: 'integer', example: 42 },
|
||
username: { type: 'string', example: 'newplayer' },
|
||
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' },
|
||
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
|
||
email_verified: {
|
||
type: 'boolean',
|
||
description: 'Whether the address above has been proved by opening a confirmation link.',
|
||
example: true,
|
||
},
|
||
email_pending: {
|
||
type: 'string',
|
||
format: 'email',
|
||
nullable: true,
|
||
description:
|
||
'An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used.',
|
||
example: null,
|
||
},
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
// Engagement Phase 1b — the change-and-verify flow.
|
||
ChangeEmailRequest: {
|
||
type: 'object',
|
||
required: ['email'],
|
||
properties: {
|
||
email: { type: 'string', format: 'email', maxLength: 255, example: 'new@example.com' },
|
||
currentPassword: {
|
||
type: 'string',
|
||
format: 'password',
|
||
description:
|
||
'Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt.',
|
||
},
|
||
},
|
||
},
|
||
PendingEmail: {
|
||
type: 'object',
|
||
description:
|
||
'The address now awaiting confirmation. The account keeps its existing address until the emailed link is used.',
|
||
properties: {
|
||
email_pending: { type: 'string', format: 'email', example: 'new@example.com' },
|
||
emailed: {
|
||
type: 'boolean',
|
||
description: 'False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later.',
|
||
example: true,
|
||
},
|
||
reason: {
|
||
type: 'string',
|
||
nullable: true,
|
||
enum: ['NOT_CONFIGURED', 'SEND_FAILED', null],
|
||
description: 'Why nothing was sent, when `emailed` is false.',
|
||
example: null,
|
||
},
|
||
},
|
||
},
|
||
EmailDedupeEntry: {
|
||
type: 'object',
|
||
description:
|
||
'One account whose email address was cleared when addresses became unique, because an older account already held it.',
|
||
properties: {
|
||
id: { type: 'integer', example: 3 },
|
||
user_id: { type: 'integer', example: 42 },
|
||
username: { type: 'string', example: 'someplayer' },
|
||
lost_address: { type: 'string', format: 'email', example: 'shared@example.com' },
|
||
cleared_at: { type: 'string', format: 'date-time' },
|
||
acknowledged_at: { type: 'string', format: 'date-time', nullable: 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'],
|
||
},
|
||
},
|
||
},
|
||
DeliveryChannel: {
|
||
type: 'object',
|
||
description: 'One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers.',
|
||
properties: {
|
||
id: { type: 'string', example: 'email' },
|
||
label: { type: 'string', example: 'Email' },
|
||
description: { type: 'string', nullable: true, example: 'A message to your verified address.' },
|
||
carriesContent: {
|
||
type: 'boolean',
|
||
description: 'False for push, which only ever sends a content-free tickle the client then pulls against.',
|
||
example: true,
|
||
},
|
||
defaultMode: {
|
||
type: 'string',
|
||
enum: ['off', 'instant', 'digest'],
|
||
description: 'The mode that applies when the user has stored no preference for an id on this channel.',
|
||
example: 'off',
|
||
},
|
||
supportsDigest: { type: 'boolean', example: true },
|
||
modes: {
|
||
type: 'array',
|
||
items: { type: 'string', enum: ['off', 'instant', 'digest'] },
|
||
description: 'The modes this channel will accept. Excludes `digest` unless supportsDigest.',
|
||
example: ['off', 'instant', 'digest'],
|
||
},
|
||
},
|
||
},
|
||
NotificationChannelPrefItem: {
|
||
type: 'object',
|
||
description: 'One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it.',
|
||
properties: {
|
||
id: { type: 'string', example: 'news.post' },
|
||
label: { type: 'string', example: 'News posts' },
|
||
description: { type: 'string', example: 'New news / Five-on-Friday / newsletter posts.' },
|
||
personal: { type: 'boolean', example: false },
|
||
requiresLinkedAccount: { type: 'boolean', example: false },
|
||
ceiling: {
|
||
type: 'string',
|
||
nullable: true,
|
||
description: 'The trigger’s audience ceiling, or null for an id with no trigger declaration.',
|
||
example: 'authenticated',
|
||
},
|
||
channels: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: 'Which channels apply. A trigger-only id has no `push` — nothing is registered to push it.',
|
||
example: ['push', 'email', 'inapp'],
|
||
},
|
||
modes: {
|
||
type: 'object',
|
||
additionalProperties: { type: 'string', enum: ['off', 'instant', 'digest'] },
|
||
description: 'Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored.',
|
||
example: { push: 'instant', email: 'off', inapp: 'off' },
|
||
},
|
||
},
|
||
},
|
||
NotificationChannelPrefs: {
|
||
type: 'object',
|
||
description: 'The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT.',
|
||
properties: {
|
||
channels: { type: 'array', items: { $ref: '#/components/schemas/DeliveryChannel' } },
|
||
items: { type: 'array', items: { $ref: '#/components/schemas/NotificationChannelPrefItem' } },
|
||
},
|
||
},
|
||
NotificationChannelPrefsUpdate: {
|
||
type: 'object',
|
||
description: 'A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission.',
|
||
properties: {
|
||
prefs: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string', example: 'news.post' },
|
||
channel: { type: 'string', example: 'email' },
|
||
mode: { type: 'string', enum: ['off', 'instant', 'digest'], example: 'digest' },
|
||
},
|
||
},
|
||
example: [{ id: 'news.post', channel: 'email', mode: 'digest' }],
|
||
},
|
||
},
|
||
},
|
||
NotificationItem: {
|
||
type: 'object',
|
||
description: 'One item in the caller’s in-app inbox.',
|
||
properties: {
|
||
id: { type: 'integer', example: 412 },
|
||
triggerId: { type: 'string', example: 'team.forum.post' },
|
||
title: { type: 'string', example: 'The Silver Anvil — new forum post' },
|
||
body: { type: 'string', nullable: true, example: 'Darrow posted in The Silver Anvil.' },
|
||
url: {
|
||
type: 'string',
|
||
nullable: true,
|
||
description: 'Site-relative path only. An absolute or protocol-relative url is never stored.',
|
||
example: '/guilds/the-silver-anvil/forum/412',
|
||
},
|
||
read: { type: 'boolean', example: false },
|
||
readAt: { type: 'string', format: 'date-time', nullable: true },
|
||
createdAt: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
NotificationInbox: {
|
||
type: 'object',
|
||
description: 'One page of the inbox, newest first. `unread` counts the whole inbox, not the page.',
|
||
properties: {
|
||
items: { type: 'array', items: { $ref: '#/components/schemas/NotificationItem' } },
|
||
hasMore: {
|
||
type: 'boolean',
|
||
description: 'Whether another page exists. Fetch it with `before` set to the last item’s id.',
|
||
example: false,
|
||
},
|
||
unread: { type: 'integer', example: 3 },
|
||
},
|
||
},
|
||
NotificationUnreadCount: {
|
||
type: 'object',
|
||
properties: { unread: { type: 'integer', example: 3 } },
|
||
},
|
||
NotificationReadResult: {
|
||
type: 'object',
|
||
description: 'The result of marking one item, or the whole inbox, read. `unread` is the count after the change, so a client never has to re-poll for the badge.',
|
||
properties: {
|
||
ok: { type: 'boolean', example: true },
|
||
changed: { type: 'integer', description: 'Mark-all only: how many items changed.', example: 3 },
|
||
unread: { type: 'integer', example: 0 },
|
||
},
|
||
},
|
||
TeamNotificationPref: {
|
||
type: 'object',
|
||
description: "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`).",
|
||
properties: {
|
||
teamId: { type: 'integer', example: 3 },
|
||
slug: { type: 'string', example: 'the-silver-hand' },
|
||
name: { type: 'string', example: 'The Silver Hand' },
|
||
archived: { type: 'boolean', example: false },
|
||
muted: { type: 'boolean', example: false },
|
||
emailMode: { type: 'string', enum: ['off', 'digest', 'immediate'], example: 'off' },
|
||
},
|
||
},
|
||
TeamNotificationPrefs: {
|
||
type: 'object',
|
||
description: 'Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty.',
|
||
properties: {
|
||
teams: { type: 'array', items: { $ref: '#/components/schemas/TeamNotificationPref' } },
|
||
},
|
||
},
|
||
// ── Moderation appeals (Phase 6c/6d) ────────────────────────────────────
|
||
Appeal: {
|
||
type: 'object',
|
||
description: 'A player-submitted moderation appeal (as returned to the player and in the staff queue).',
|
||
properties: {
|
||
id: { type: 'integer', example: 12 },
|
||
mod_action_id: { type: 'integer', example: 340 },
|
||
discord_user_id: { type: 'string', example: '216734083584917504' },
|
||
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
||
user_id: { type: 'integer', nullable: true, example: 42 },
|
||
status: {
|
||
type: 'string',
|
||
enum: ['pending', 'under_review', 'approved', 'denied', 'withdrawn'],
|
||
example: 'pending',
|
||
},
|
||
submitted_text: { type: 'string', example: 'I was banned by mistake — please review.' },
|
||
staff_response: { type: 'string', nullable: true, example: null },
|
||
handled_by_user_id: { type: 'integer', nullable: true, example: null },
|
||
handled_by_tag: { type: 'string', nullable: true, example: null },
|
||
reversal_status: {
|
||
type: 'string',
|
||
enum: ['none', 'done', 'failed'],
|
||
description: 'Discord-reversal outcome. done/failed only after an approval; none otherwise.',
|
||
example: 'none',
|
||
},
|
||
submitted_at: { type: 'string', format: 'date-time' },
|
||
resolved_at: { type: 'string', format: 'date-time', nullable: true, example: null },
|
||
action_target_tag: { type: 'string', nullable: true, example: 'Rogue#1234', description: 'Snapshot of the original action target tag (from mod_actions).' },
|
||
action_reason: { type: 'string', nullable: true, example: 'Spam' },
|
||
action_created_at: { type: 'string', format: 'date-time', nullable: true },
|
||
action_duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
||
submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
|
||
},
|
||
},
|
||
ContentReport: {
|
||
type: 'object',
|
||
description: 'A member-raised report about a piece of content (TEAMS.md §5.6). '
|
||
+ 'Generic by design: `targetType` is a string rather than an enum in the schema '
|
||
+ 'because a wiki page or a news comment is meant to become a new value here, not a new queue. '
|
||
+ 'Reports reach SITE STAFF only — there is no leader-facing view of this queue, '
|
||
+ 'because a Team\'s leaders are exactly the people who will not report their own Team.',
|
||
properties: {
|
||
id: { type: 'integer', example: 41 },
|
||
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
|
||
targetId: { type: 'integer', example: 812 },
|
||
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
|
||
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
|
||
reporterDeleted: { type: 'boolean', example: false },
|
||
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
|
||
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
|
||
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
|
||
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
|
||
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
|
||
handledAt: { type: 'string', format: 'date-time', nullable: true },
|
||
createdAt: { type: 'string', format: 'date-time' },
|
||
target: {
|
||
type: 'object',
|
||
nullable: true,
|
||
description: 'The reported content, already resolved so triage never means hunting. '
|
||
+ 'NULL when the target has since been hard-deleted — the report still lists, because '
|
||
+ '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. '
|
||
+ 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).',
|
||
properties: {
|
||
kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' },
|
||
threadId: { type: 'integer', nullable: true, example: 19 },
|
||
threadTitle: { type: 'string', nullable: true, example: 'Raid night' },
|
||
postId: { type: 'integer', nullable: true, example: 812 },
|
||
uploadId: { type: 'integer', nullable: true },
|
||
title: { type: 'string', nullable: true },
|
||
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
|
||
author: { type: 'string', nullable: true, example: 'someone' },
|
||
uploader: { type: 'string', nullable: true },
|
||
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
|
||
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
|
||
filename: { type: 'string', nullable: true },
|
||
url: { type: 'string', nullable: true, example: '/uploads/a1b2c3.png' },
|
||
mimetype: { type: 'string', nullable: true, example: 'image/png', description: 'The sniffed type, never the client\'s header.' },
|
||
byteSize: { type: 'integer', nullable: true, example: 184320 },
|
||
deleted: { type: 'boolean', nullable: true },
|
||
createdAt: { type: 'string', format: 'date-time', nullable: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
AppealQueueItem: {
|
||
allOf: [{ $ref: '#/components/schemas/Appeal' }],
|
||
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
|
||
},
|
||
AppealResolveResult: {
|
||
allOf: [
|
||
{ $ref: '#/components/schemas/Appeal' },
|
||
{
|
||
type: 'object',
|
||
properties: {
|
||
reversal: {
|
||
type: 'object',
|
||
description: 'What the approval attempted against Discord.',
|
||
properties: {
|
||
attempted: { type: 'boolean', example: true },
|
||
ok: { type: 'boolean', example: true },
|
||
reversal_status: { type: 'string', enum: ['none', 'done', 'failed'], example: 'done' },
|
||
bot_status: { type: 'integer', nullable: true, example: 200, description: 'HTTP status from the bot internal call, or null when no call was made.' },
|
||
error: { type: 'string', nullable: true, example: null },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
AppealEligibleAction: {
|
||
type: 'object',
|
||
description: 'A ban/mute mod_action the caller may appeal (no active appeal outstanding).',
|
||
properties: {
|
||
id: { type: 'integer', example: 340, description: 'mod_action id — pass as mod_action_id when submitting.' },
|
||
action_type: { type: 'string', enum: ['ban', 'mute'], example: 'ban' },
|
||
target_tag: { type: 'string', nullable: true, example: 'Rogue#1234' },
|
||
reason: { type: 'string', nullable: true, example: 'Spam' },
|
||
duration_seconds: { type: 'integer', nullable: true, example: 86400 },
|
||
created_at: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
CreateAppealRequest: {
|
||
type: 'object',
|
||
required: ['mod_action_id', 'submitted_text'],
|
||
properties: {
|
||
mod_action_id: { type: 'integer', example: 340, description: 'The ban/mute mod_action to appeal (must belong to the caller).' },
|
||
submitted_text: { type: 'string', minLength: 1, maxLength: 4000, example: 'I was banned by mistake — please review.' },
|
||
},
|
||
},
|
||
ResolveAppealRequest: {
|
||
type: 'object',
|
||
required: ['status'],
|
||
properties: {
|
||
status: { type: 'string', enum: ['approved', 'denied'], example: 'approved' },
|
||
staff_response: { type: 'string', maxLength: 4000, nullable: true, example: 'Reviewed — reversing the ban.' },
|
||
},
|
||
},
|
||
TotpCodeRequest: {
|
||
type: 'object',
|
||
required: ['code'],
|
||
properties: { code: { type: 'string', example: '123456' } },
|
||
},
|
||
SiteModeRequest: {
|
||
type: 'object',
|
||
required: ['mode'],
|
||
properties: { mode: { type: 'string', enum: ['live', 'maintenance'], example: 'live' } },
|
||
},
|
||
UnbanRequest: {
|
||
type: 'object',
|
||
required: ['ip'],
|
||
properties: { ip: { type: 'string', example: '203.0.113.5' } },
|
||
},
|
||
// ── Actual mutation-response shapes ─────────────────────────────────────
|
||
// These endpoints do NOT return the generic { message } envelope; they echo
|
||
// the affected resource id/slug or a boolean flag. Documented here as-is so
|
||
// the spec matches the controllers. (The shapes are intentionally recorded
|
||
// rather than normalized — see the audit note if standardizing later.)
|
||
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).' },
|
||
capabilities: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
example: ['events'],
|
||
description:
|
||
'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. A backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. Treat an unknown string as absent.',
|
||
},
|
||
},
|
||
},
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
PublicEventEntry: {
|
||
type: 'object',
|
||
description:
|
||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||
properties: {
|
||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||
title: { type: 'string', example: 'The Yew Invasion' },
|
||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||
seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
|
||
scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
|
||
timezone: {
|
||
type: 'string',
|
||
example: 'America/New_York',
|
||
description:
|
||
'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
|
||
},
|
||
status: {
|
||
type: 'string',
|
||
enum: ['scheduled', 'live', 'completed', 'cancelled'],
|
||
description:
|
||
'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
|
||
},
|
||
live: { type: 'boolean', example: false },
|
||
adjusted: {
|
||
type: 'boolean',
|
||
description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
|
||
},
|
||
shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
|
||
},
|
||
},
|
||
PublicEventCalendar: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean', example: true },
|
||
window: {
|
||
type: 'object',
|
||
properties: {
|
||
from: { type: 'string', format: 'date-time' },
|
||
to: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
|
||
truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
|
||
},
|
||
},
|
||
PublicEventOccurrence: {
|
||
type: 'object',
|
||
description:
|
||
'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
|
||
properties: {
|
||
runId: { type: 'integer', example: 3692 },
|
||
scheduledFor: { type: 'string', format: 'date-time' },
|
||
timezone: { type: 'string', example: 'America/New_York' },
|
||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||
live: { type: 'boolean' },
|
||
scope: {
|
||
type: 'string',
|
||
nullable: true,
|
||
description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
|
||
},
|
||
phase: {
|
||
type: 'string',
|
||
nullable: true,
|
||
example: 'The assault',
|
||
description:
|
||
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
|
||
},
|
||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
},
|
||
},
|
||
PublicEventParticipant: {
|
||
type: 'object',
|
||
description:
|
||
'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
|
||
properties: {
|
||
name: { type: 'string', nullable: true, example: 'Aldric' },
|
||
score: { type: 'number', example: 1420 },
|
||
rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
|
||
meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
|
||
},
|
||
},
|
||
PublicEvent: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean', example: true },
|
||
event: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string', example: 'The Yew Invasion' },
|
||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||
summary: { type: 'string', nullable: true },
|
||
body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
|
||
imageUrl: { type: 'string', nullable: true },
|
||
timezone: { type: 'string', example: 'America/New_York' },
|
||
series: {
|
||
type: 'object',
|
||
nullable: true,
|
||
properties: { name: { type: 'string' }, slug: { type: 'string' } },
|
||
},
|
||
live: { type: 'boolean' },
|
||
current: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||
next: { $ref: '#/components/schemas/PublicEventOccurrence' },
|
||
upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||
past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
|
||
results: {
|
||
type: 'object',
|
||
nullable: true,
|
||
description:
|
||
'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
|
||
properties: {
|
||
runId: { type: 'integer' },
|
||
scheduledFor: { type: 'string', format: 'date-time' },
|
||
publishedAt: { type: 'string', format: 'date-time' },
|
||
participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
PublicEventSeries: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean', example: true },
|
||
series: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string', example: 'The Yew Campaign' },
|
||
slug: { type: 'string', example: 'the-yew-campaign' },
|
||
description: { type: 'string', nullable: true },
|
||
events: {
|
||
type: 'array',
|
||
description: 'The listed events in the arc, in the order an editor arranged them.',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string' },
|
||
slug: { type: 'string' },
|
||
summary: { type: 'string', nullable: true },
|
||
imageUrl: { type: 'string', nullable: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
PlayerEventHistory: {
|
||
type: 'object',
|
||
properties: {
|
||
ok: { type: 'boolean', example: true },
|
||
entries: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
|
||
runId: { type: 'integer' },
|
||
title: { type: 'string' },
|
||
slug: { type: 'string' },
|
||
seriesName: { type: 'string', nullable: true },
|
||
seriesSlug: { type: 'string', nullable: true },
|
||
scheduledFor: { type: 'string', format: 'date-time' },
|
||
startedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
endedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
timezone: { type: 'string' },
|
||
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
|
||
joinedAt: { type: 'string', format: 'date-time' },
|
||
score: { type: 'number' },
|
||
rank: {
|
||
type: 'integer',
|
||
nullable: true,
|
||
description: 'Null until results were published — a real state rather than an error.',
|
||
},
|
||
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
meta: { type: 'object', nullable: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
PublicTeamMember: {
|
||
type: 'object',
|
||
description:
|
||
'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.',
|
||
properties: {
|
||
displayName: { type: 'string', nullable: true, example: 'Aldric' },
|
||
rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' },
|
||
isLeader: { type: 'boolean', example: true },
|
||
online: { type: 'boolean', example: false },
|
||
linked: { type: 'boolean', example: true },
|
||
},
|
||
},
|
||
PublicTeamRoster: {
|
||
type: 'object',
|
||
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
|
||
properties: {
|
||
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
|
||
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
projected: {
|
||
type: 'boolean',
|
||
description:
|
||
'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.',
|
||
example: true,
|
||
},
|
||
},
|
||
},
|
||
PublicTeamActivityItem: {
|
||
type: 'object',
|
||
description:
|
||
'`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.',
|
||
properties: {
|
||
id: { type: 'integer', example: 4821 },
|
||
source: { type: 'string', description: '`core` or a module id.', example: 'uo' },
|
||
kind: { type: 'string', example: 'uo.champion.completed' },
|
||
summary: { type: 'string', example: 'Completed Champion Neira' },
|
||
visibility: { type: 'string', enum: ['public', 'members'] },
|
||
occurredAt: { type: 'string', format: 'date-time' },
|
||
payload: { type: 'object', nullable: true, additionalProperties: true },
|
||
},
|
||
},
|
||
PublicTeamActivity: {
|
||
type: 'object',
|
||
properties: {
|
||
items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } },
|
||
total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 },
|
||
limit: { type: 'integer', example: 50 },
|
||
offset: { type: 'integer', example: 0 },
|
||
scope: {
|
||
type: 'string',
|
||
enum: ['public', 'members'],
|
||
description:
|
||
'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.',
|
||
},
|
||
},
|
||
},
|
||
PlayerTeamList: {
|
||
type: 'object',
|
||
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
|
||
properties: {
|
||
teams: {
|
||
type: 'array',
|
||
items: {
|
||
allOf: [{ $ref: '#/components/schemas/PublicTeam' }],
|
||
type: 'object',
|
||
properties: {
|
||
reason: {
|
||
type: 'string',
|
||
enum: ['membership', 'grant', 'both'],
|
||
description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.',
|
||
},
|
||
isLeader: { type: 'boolean' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
PlayerTeamAccess: {
|
||
type: 'object',
|
||
properties: {
|
||
slug: { type: 'string' },
|
||
allowed: { type: 'boolean' },
|
||
viaMembership: { type: 'boolean' },
|
||
viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' },
|
||
isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' },
|
||
},
|
||
},
|
||
AdminTeam: {
|
||
type: 'object',
|
||
description: 'The full staff view, including what a staff decision overrode.',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
moduleId: { type: 'string', example: 'uo' },
|
||
externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' },
|
||
slug: { type: 'string' },
|
||
name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' },
|
||
displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' },
|
||
displayNameOverride: { type: 'string', nullable: true },
|
||
abbr: { type: 'string', nullable: true },
|
||
status: { type: 'string', enum: ['active', 'archived'] },
|
||
hidden: { type: 'boolean' },
|
||
hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] },
|
||
hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' },
|
||
nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' },
|
||
memberCount: { type: 'integer' },
|
||
linkedCount: { type: 'integer' },
|
||
onlineCount: { type: 'integer' },
|
||
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' },
|
||
succeededBy: { type: 'integer', nullable: true },
|
||
createdAt: { type: 'string', format: 'date-time' },
|
||
archivedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
archivedReason: { type: 'string', nullable: true },
|
||
meta: { type: 'object', nullable: true, additionalProperties: true },
|
||
members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } },
|
||
grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } },
|
||
pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } },
|
||
},
|
||
},
|
||
AdminTeamMember: {
|
||
type: 'object',
|
||
properties: {
|
||
memberKey: { type: 'string', example: '0x40012ab3' },
|
||
displayName: { type: 'string', nullable: true },
|
||
userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' },
|
||
rankLabel: { type: 'string', nullable: true },
|
||
isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' },
|
||
isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' },
|
||
leaderOverride: {
|
||
type: 'object',
|
||
nullable: true,
|
||
properties: {
|
||
effect: { type: 'string', enum: ['grant', 'deny'] },
|
||
reason: { type: 'string', nullable: true },
|
||
by: { type: 'string', nullable: true },
|
||
at: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
online: { type: 'boolean' },
|
||
status: { type: 'string', enum: ['active', 'departed'] },
|
||
firstSeenAt: { type: 'string', format: 'date-time' },
|
||
lastSeenAt: { type: 'string', format: 'date-time' },
|
||
departedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
},
|
||
},
|
||
AdminTeamList: {
|
||
type: 'object',
|
||
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
|
||
properties: {
|
||
teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } },
|
||
syncState: {
|
||
type: 'object',
|
||
nullable: true,
|
||
description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.',
|
||
properties: {
|
||
moduleId: { type: 'string' },
|
||
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
|
||
lastSuccessAt: { type: 'string', format: 'date-time', nullable: true },
|
||
consecutiveFailures: { type: 'integer' },
|
||
lastError: { type: 'string', nullable: true },
|
||
pendingEmptySince: { type: 'string', format: 'date-time', nullable: true },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
TeamGrant: {
|
||
type: 'object',
|
||
description:
|
||
'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
team_id: { type: 'integer' },
|
||
user_id: { type: 'integer', nullable: true },
|
||
username: { type: 'string', nullable: true },
|
||
granted_by: { type: 'integer', nullable: true },
|
||
granted_username: { type: 'string', nullable: true },
|
||
granted_at: { type: 'string', format: 'date-time' },
|
||
reason: { type: 'string', nullable: true },
|
||
revoked_by: { type: 'integer', nullable: true },
|
||
revoked_username: { type: 'string', nullable: true },
|
||
revoked_at: { type: 'string', format: 'date-time', nullable: true },
|
||
revoke_reason: { type: 'string', nullable: true },
|
||
},
|
||
},
|
||
TeamGrantLedger: {
|
||
type: 'object',
|
||
properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } },
|
||
},
|
||
TeamModerationRequest: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
team_id: { type: 'integer' },
|
||
team_name: { type: 'string' },
|
||
team_slug: { type: 'string' },
|
||
action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] },
|
||
payload: { type: 'object', nullable: true, additionalProperties: true },
|
||
reason: { type: 'string', nullable: true },
|
||
requested_by: { type: 'integer', nullable: true },
|
||
requested_username: { type: 'string', nullable: true },
|
||
requested_at: { type: 'string', format: 'date-time' },
|
||
status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] },
|
||
decided_by: { type: 'integer', nullable: true },
|
||
decided_username: { type: 'string', nullable: true },
|
||
decided_at: { type: 'string', format: 'date-time', nullable: true },
|
||
decision_note: { type: 'string', nullable: true },
|
||
},
|
||
},
|
||
TeamRequestQueue: {
|
||
type: 'object',
|
||
properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } },
|
||
},
|
||
TeamReviewQueue: {
|
||
type: 'object',
|
||
description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.',
|
||
properties: {
|
||
teams: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
name: { type: 'string', example: 'Admin' },
|
||
slug: { type: 'string' },
|
||
hidden_term: { type: 'string', example: 'admin' },
|
||
display_name_override: { type: 'string', nullable: true },
|
||
member_count: { type: 'integer' },
|
||
created_at: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
TeamIntegrationRow: {
|
||
type: 'object',
|
||
description:
|
||
'One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits.',
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
platform: { type: 'string', example: 'discord' },
|
||
team_id: { type: 'integer', nullable: true },
|
||
team_name: { type: 'string', nullable: true },
|
||
events: { type: 'array', items: { type: 'string', example: 'team.announcement' } },
|
||
channel_ref: { type: 'string', nullable: true, example: '1024839201048392010' },
|
||
enabled: { type: 'boolean' },
|
||
members_ack: {
|
||
type: 'boolean',
|
||
description:
|
||
'The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes.',
|
||
},
|
||
members_ack_by: { type: 'integer', nullable: true },
|
||
members_ack_username: { type: 'string', nullable: true },
|
||
members_ack_at: { type: 'string', format: 'date-time', nullable: true },
|
||
updated_at: { type: 'string', format: 'date-time' },
|
||
},
|
||
},
|
||
TeamIntegrationConfig: {
|
||
type: 'object',
|
||
properties: {
|
||
platform: { type: 'string', example: 'discord' },
|
||
events: {
|
||
type: 'array',
|
||
description: 'Every event that may be bridged, and whether it carries members-only content.',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string', example: 'team.forum.post' },
|
||
membersOnly: { type: 'boolean' },
|
||
},
|
||
},
|
||
},
|
||
rows: { type: 'array', items: { $ref: '#/components/schemas/TeamIntegrationRow' } },
|
||
},
|
||
},
|
||
TeamVoiceSettings: {
|
||
type: 'object',
|
||
description:
|
||
'The operator\'s voice controls. Access is granted with a role per Team, so Discord\'s guild-wide cap of 250 roles — not a per-channel overwrite budget — is the ceiling on how many Teams can have voice.',
|
||
properties: {
|
||
enabled: {
|
||
type: 'boolean',
|
||
description:
|
||
'Switching this off suspends the reconciler in both directions and leaves existing channels standing. A checkbox does not delete structure in somebody\'s guild; remove channels individually instead.',
|
||
},
|
||
minMembers: {
|
||
type: 'integer',
|
||
example: 5,
|
||
description: 'Every active member counts, whatever they have linked.',
|
||
},
|
||
graceDays: {
|
||
type: 'integer',
|
||
example: 7,
|
||
description:
|
||
'How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel id.',
|
||
},
|
||
categoryRef: {
|
||
type: 'string',
|
||
nullable: true,
|
||
description: 'The parent category, created by the bot on the first pass that needs one and stored here.',
|
||
},
|
||
staffRoles: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description:
|
||
'Roles allowed into every Team channel. Guild administrators already bypass overwrites, so this is for staff who are not administrators.',
|
||
},
|
||
roleCap: { type: 'integer', example: 250 },
|
||
},
|
||
},
|
||
TeamVoicePreflight: {
|
||
type: 'object',
|
||
description:
|
||
'The bot\'s own answer about whether it can do the job. Asked before voice may be switched on and again at the top of every pass — the operator invites the bot by hand, so nothing else in the system knows what permissions it was granted.',
|
||
properties: {
|
||
ready: { type: 'boolean' },
|
||
connected: { type: 'boolean' },
|
||
missingPermissions: { type: 'array', items: { type: 'string', example: 'Manage Roles' } },
|
||
roleCount: {
|
||
type: 'integer',
|
||
description: 'Roles in the guild, all of them — the cap is shared with every role the operator created themselves.',
|
||
},
|
||
roleCap: { type: 'integer', example: 250 },
|
||
botRolePosition: {
|
||
type: 'integer',
|
||
description:
|
||
'The bot can only grant roles below its own. A bot at the bottom of the list creates roles it cannot hand to anybody.',
|
||
},
|
||
reason: { type: 'string', nullable: true },
|
||
},
|
||
},
|
||
TeamVoiceRow: {
|
||
type: 'object',
|
||
description: 'One Team\'s provisioned channel and role, as core last believed them.',
|
||
properties: {
|
||
teamId: { type: 'integer' },
|
||
teamName: { type: 'string' },
|
||
teamSlug: { type: 'string' },
|
||
memberCount: { type: 'integer' },
|
||
linkedCount: { type: 'integer' },
|
||
channelRef: { type: 'string', nullable: true },
|
||
roleRef: { type: 'string', nullable: true },
|
||
state: { type: 'string', enum: ['none', 'active', 'pending_removal', 'error'] },
|
||
removeAfter: { type: 'string', format: 'date-time', nullable: true },
|
||
lastError: { type: 'string', nullable: true },
|
||
syncedAt: { type: 'string', format: 'date-time', nullable: true },
|
||
},
|
||
},
|
||
TeamVoiceConfig: {
|
||
type: 'object',
|
||
properties: {
|
||
platform: { type: 'string', example: 'discord' },
|
||
settings: { $ref: '#/components/schemas/TeamVoiceSettings' },
|
||
preflight: { $ref: '#/components/schemas/TeamVoicePreflight' },
|
||
rows: { type: 'array', items: { $ref: '#/components/schemas/TeamVoiceRow' } },
|
||
lastPass: { $ref: '#/components/schemas/TeamVoicePassResult' },
|
||
},
|
||
},
|
||
TeamVoicePassResult: {
|
||
type: 'object',
|
||
description:
|
||
'What one reconciliation pass did, or why it did nothing. `ran: false` is the ordinary answer on a deployment with voice off, with a stale Team projection, or with a bot that cannot manage channels and roles — and the three read differently in `reason` because an operator fixes them in three different places.',
|
||
properties: {
|
||
ran: { type: 'boolean' },
|
||
reason: { type: 'string', nullable: true },
|
||
synced: { type: 'integer' },
|
||
created: { type: 'integer' },
|
||
scheduled: { type: 'integer' },
|
||
removed: { type: 'integer' },
|
||
failed: { type: 'integer' },
|
||
pendingMemberOps: {
|
||
type: 'integer',
|
||
description: 'Role grants a bounded pass could not fit. Non-zero asks for another pass rather than waiting out the interval.',
|
||
},
|
||
},
|
||
},
|
||
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 */
|